learn / 01·7 — under workbook · postures

where the bytesMAYlive

The moment a workbook holds something private, the one-file promise looks like a threat. The answer isn't a login screen drawn over the data — it's a posture: a declared stance that decides where bytes are allowed to exist. Public bytes bake into the file; gated bytes never leave the runtime. And the rule is enforced where it can't be skipped — at build time.

the stances12 min read
A small traveler in a bright white plaza facing three monumental gates in a row — the first standing wide open onto sunlit terraces, the second open only as an archway with a glowing turnstile and a lit hall held behind it, the third a sheer sealed slab with a single keyhole of light — 1970s sci-fi style

a login screen drawn over a LEAK

The parent lesson pitched the whole idea in one move: the file carries everything — hand it to anyone. Then your workbook grows a paid dataset, or a client's numbers, and the pitch flips into a threat. If the data rides in the file, anyone with the file has the data.

The industry's reflex is to draw a login screen over it. You have seen this artifact a hundred times: the "protected" HTML export, the password prompt in JavaScript, the dashboard that politely hides its numbers until you sign in. And you know how it ends — View Source defeats the lock, because the gate and the secret traveled in the same file. The attacker owns the client and the bytes; a guard that ships alongside the treasure guards nothing.

The parent page's FAQ names this failure and promises it never happens here. This deep dive is the how. The short version: security is not a property you bolt onto the file. It's a stance you declare — and the stance decides where the bytes are allowed to live at all.

a stance, not a FEATURE

pos·ture /ˈpɒs·tʃər/ noun

1. a declared security stance on a workbook — public, gated_data, or gated_route — that decides which bytes may be baked into a static artifact and which may only ever exist behind the runtime, released per-request after auth.

The set is exactly three, and each answers one question — what is public: everything, the shell, or nothing?

public — fully public. The whole workbook may be rendered into a static, self-contained artifact: one HTML file, no secrets, openable anywhere forever. gated_data — the shell is public, but the data and capabilities are fetched from the runtime per-request, behind auth. The app behind the page is the runtime, not the file. gated_route — even the shell, even the workbook's existence, is private; the runtime serves it only to an authenticated caller.

The canon table, straight from the protocol doc:

postureshelldata / capabilitiesstatic-host safe?
publicpublicpublic — may be inlinedYES
gated_datapublicruntime-gated, per-requestNO
gated_routeruntime-gatedruntime-gatedNO

Declaring one is a line of metadata: a posture key on the workbook, or :PUBLISH_ACCESS: in publish.org. The parser is forgiving where it's safe and strict where it matters — public | gated-data | gated-route, underscores normalized to dashes, case-insensitive, whitespace trimmed; an empty or missing value parses to public; an unknown string is an error, not a guess. A security stance is the one place a parser should refuse to be creative.

the anti-leak RULE

Everything on this page hangs off one law, stated in the access module's own doc: a gated workbook must never be rendered into a public static artifact — because a static file ships its contents to anyone with the URL, so client-side gating is theater. The secret can't be guarded inside the artifact. It has to never enter the artifact.

The primitive that carries the law is one function, static_safe?/1 — true for public, false for both gated postures. Every build decision branches on it:

flowchart LR
  p["the workbook's posture"] --> q{"static_safe?"}
  q -- "public — true" --> bake["bake — the whole workbook
into one static html, no secrets to leak"] q -- "gated_data · gated_route — false" --> stay["never inlined — bytes stay behind
the runtime, released per-request, after auth"] style p fill:#ffffff,stroke:#121316 style q fill:#fbfaf6,stroke:#121316 style bake fill:#13d943,stroke:#121316 style stay fill:#a8d4f0,stroke:#121316,stroke-width:2.5px

Read the right side of that fork carefully — it's the whole design. The gated branch doesn't say "inlined behind a password". It says the bytes do not go into the file. There is no version of a gated workbook whose secrets sit in a static artifact waiting for a cleverer attacker; the artifact that exists just doesn't contain them.

One deliberate nuance: even the desktop app — running on a machine you trust — gets only a shell for gated workbooks, fetching protected content over the runtime connection. Not because your laptop is the enemy, but because a "desktop" artifact is still a file, and files get copied to web hosts. The invariant doesn't trust contexts. It trusts construction.

enforced twice, at BUILD time

An invariant that lives only in request handlers is a hope. This one is enforced where you can't route around it — in the pipeline that produces artifacts, at two separate guards.

Guard one: the publish validator. There are four publish targets — cloudflare-pages, gh-pages, self-hosted, desktop-app. The first two are public static hosts, and the validator flatly rejects a gated posture aimed at either. Here's a publish.org for a fictional gated workbook, pointed at the wrong door:

:PROPERTIES:
:PUBLISH_TARGET:  cloudflare-pages
:PUBLISH_PROJECT: acme-pricing
:PUBLISH_ACCESS:  gated-data
:END:

wb publish validate fails with the error, verbatim from the source:

PUBLISH_ACCESS: gated-data cannot publish to cloudflare-pages — a public static
host ships the whole workbook to anyone (client-side gating is theater).
Use self-hosted (the runtime gates per-request) or desktop-app (local shell).

Flip :PUBLISH_TARGET: to self-hosted — where the runtime gates every request — and validation passes. The invariant is a build error, not a runtime hope. You cannot ship the leak, because the pipeline won't produce the file. The refusal isn't folklore, either: it's pinned by the test suite, error string and all.

Guard two: the compiler. The desktop-app target accepts gated workbooks — but its compiler branches on static_safe?. Public posture: the rendered page is inlined, plus a runtime-connect bootstrap. Gated posture: it emits a gated shell — a generated public page with no workbook content inside, whose entire body says the workbook is protected, please sign in. After auth, the shell fetches the real content from the runtime — by default from /api/w/<slug>/html. The source comment gives the reason in one line: even a desktop artifact can be copied to a web host.

flowchart TD
  v["wb publish validate — target × posture"] --> t{"target?"}
  t -- "cloudflare-pages · gh-pages" --> s{"posture?"}
  s -- "public" --> ok["ship — static artifact, the whole workbook"]
  s -- "gated-*" --> rej["REJECTED — build error,
the leaking file is never produced"] t -- "self-hosted" --> ok2["ship — the runtime gates per-request"] t -- "desktop-app" --> c{"static_safe?"} c -- "true" --> inl["inline — rendered page
+ runtime bootstrap"] c -- "false" --> sh["gated shell — zero payload,
auths, then fetches over the wire"] style v fill:#ffffff,stroke:#121316 style t fill:#fbfaf6,stroke:#121316 style s fill:#fbfaf6,stroke:#121316 style c fill:#fbfaf6,stroke:#121316 style ok fill:#13d943,stroke:#121316 style ok2 fill:#13d943,stroke:#121316 style inl fill:#13d943,stroke:#121316 style rej fill:#f3c5a3,stroke:#121316,stroke-width:2.5px style sh fill:#a8d4f0,stroke:#121316,stroke-width:2.5px

The gated shell is worth opening once, just to see the invariant made visible: a real HTML page, viewable by anyone, whose View Source contains a loading message, a sign-in prompt, and a fetch — and not one byte of the workbook. The thing the attacker downloads is honestly empty.

the twelve-line JUDGE

depth rung · skippable — the decision function, cell by cell

At request time, the posture meets a tiny total function: Workbooks.Access.enforce(posture, demand, identity), which returns :allow or {:deny, :auth_required}. The demand vocabulary has three words: :shell — the app and its structure; :data — the protected content and capabilities; :full — both, inlined. Three postures, three demands, one identity — the whole decision is a matrix:

posture:shell:data:full
publicanyone — even anonymousanyoneanyone
gated_dataanyoneauthenticated onlyauthenticated only
gated_routeauthenticated onlyauthenticated onlyauthenticated only

And "authenticated" is strict: the identity must be a map with a binary user_id that is not nil, not empty, and not "dev". The anonymous dev fallback — the identity every local request gets when nothing stronger is presented — explicitly does not count. These are the actual assertions from the test suite:

Access.enforce(:public,      :full,  nil)                 #=> :allow
Access.enforce(:gated_data,  :shell, nil)                 #=> :allow
Access.enforce(:gated_data,  :data,  nil)                 #=> {:deny, :auth_required}
Access.enforce(:gated_data,  :data,  %{user_id: "u-1"})   #=> :allow
Access.enforce(:gated_data,  :data,  %{user_id: "dev"})   #=> {:deny, :auth_required}  — dev never unlocks
Access.enforce(:gated_route, :shell, nil)                 #=> {:deny, :auth_required}  — even the shell

The dev-identity line is the trap worth memorizing. Local development cannot fake its way past a gate — so "it worked on my machine" can never quietly mean "the gate was open on my machine". If gating appears to work in dev, it's because you presented a real identity, not because the judge went easy on you.

five rungs to an IDENTITY

The judge needs an identity to judge. Identities come out of the auth plug — a ladder of five rungs, checked top to bottom, first match wins, with the available rungs gated by the deploy's tenancy mode.

First — the public allowlist. /health and the /.well-known discovery docs are always open, for a bootstrap reason the source spells out: a client must be able to read the capabilities document to learn which rung it needs before it has any credential. The handshake at /.well-known/workbooks-runtime advertises auth.rungoidc-jwt on a multi-tenant deploy, trusted otherwise — plus the issuer and JWKS URL.

Second — the desktop per-boot token. Only when the runtime boots in desktop mode: 24 random bytes, Base64url, minted fresh every boot, compared in constant time, scoping to the local tenant. The token reaches the desktop shell through a discovery file — runtime.json, permissions 0600, holding port, token, pid, and scheme. No accounts, no config: possession of the local file is the credential, and it expires with the process.

Third — the shared-secret lock. Set WB_PUBLIC_BEARER and the deploy is locked: Authorization: Bearer with that exact secret maps to the configured tenant, and any other or absent bearer is a 401 — with no dev fallback. This is the lock for a personal cloud deploy: one secret, one tenant, nothing anonymous.

Fourth — the JWT rung. Production multi-tenant: BetterAuth-issued JWTs, verified RS256 against a JWKS URL in production or HS256 from a shared secret in local testing. The claims carry the user, the organization as tenant — falling back to the user for solo accounts — and the session; tokens live an hour. On a multi-tenant deploy with no bearer at all, the answer is 401 tenant_required, because isolation can't rest on a spoofable header.

Fifth — the dev fallback. Only when the deploy is neither locked nor multi-tenant: an x-tenant header, or just "dev", becomes the tenant. Note the shape it produces — a user_id of "dev" — which is exactly the identity the judge refuses. The bottom rung gets you in the door for development; it never gets you through a gate.

flowchart TD
  req["request arrives"] --> al{"path on the public allowlist?
/health · /.well-known/…"} al -- "yes" --> open["open — read the capabilities doc
before you hold any credential"] al -- "no" --> dk{"desktop mode + bearer
= the per-boot token?"} dk -- "yes" --> t1["identity — tenant local"] dk -- "no" --> lk{"WB_PUBLIC_BEARER set?"} lk -- "yes — bearer matches" --> t2["identity — tenant from WB_TENANT"] lk -- "yes — anything else" --> e1["401 — locked, no dev fallback"] lk -- "no" --> jw{"bearer is a JWT?"} jw -- "valid" --> t3["identity — user + organization as tenant"] jw -- "none, multi-tenant" --> e2["401 tenant_required"] jw -- "none, single-tenant" --> dv["dev fallback — user_id dev
never satisfies a gate"] style req fill:#ffffff,stroke:#121316 style al fill:#fbfaf6,stroke:#121316 style dk fill:#fbfaf6,stroke:#121316 style lk fill:#fbfaf6,stroke:#121316 style jw fill:#fbfaf6,stroke:#121316 style open fill:#13d943,stroke:#121316 style t1 fill:#13d943,stroke:#121316 style t2 fill:#13d943,stroke:#121316 style t3 fill:#13d943,stroke:#121316 style e1 fill:#f3c5a3,stroke:#121316 style e2 fill:#f3c5a3,stroke:#121316 style dv fill:#d9dbd3,stroke:#121316,stroke-width:2.5px

Every deny on the ladder speaks one envelope — {"error": {"code", "message", "retryable"}} — from a closed vocabulary of seven codes, with unauthorized and tenant_required both mapping to HTTP 401. Uniform refusals matter more than they look: they're what keeps a denied request from becoming an information channel, which the next section makes concrete.

watch the gate HOLD

depth rung · skippable — posture enforcement, live, end-to-end

The clearest place to watch posture, judge, and ladder work together is the sealed-bundle key release. The bundles lesson taught the cipher layer: a sealed entry ships as real AES-256-GCM ciphertext, one fresh key per entry, and the key never rides in the bundle — it sits in an escrow table at the runtime, keyed by workbook id and entry path. This page owns the other layer: who gets the key, and when.

The release endpoint is POST /rcp/key/:key_id. A sealed entry is gated by definition, so the route asks the judge exactly one question — enforce(:gated_data, :data, identity) — and branches on the answer:

sequenceDiagram
  participant C as client — holds the sealed bundle
  participant R as the runtime
  participant A as Access.enforce
  participant E as the key escrow
  C->>R: POST /rcp/key/wb-acme:c2VjcmV0Lmh0bWw
  R->>A: enforce(:gated_data, :data, identity)
  alt identity is real
    A-->>R: :allow
    R->>E: fetch the entry's key
    E-->>R: 32-byte key
    R-->>C: key_id · algo aes-256-gcm · key
  else anonymous, or dev
    A-->>R: deny — auth_required
    R-->>C: 401 — the same envelope as any unauthorized call
  end
  

Two curls against a runtime holding an escrowed key tell the whole story. Anonymous first — the gate holds:

$ curl -s -X POST https://rt.example.com/rcp/key/wb-acme:c2VjcmV0Lmh0bWw
{"error":{"code":"unauthorized","message":"unauthorized","retryable":false}}

# authenticated — here, a locked deploy's shared-secret rung
$ curl -s -X POST -H "Authorization: Bearer $WB_PUBLIC_BEARER" \
    https://rt.example.com/rcp/key/wb-acme:c2VjcmV0Lmh0bWw
{"key_id":"wb-acme:c2VjcmV0Lmh0bWw","algo":"aes-256-gcm","key":"<base64, 32 bytes>"}

The key id shape is real — workbook id, a colon, the entry path base64url-encoded. And notice what the deny doesn't say: the anonymous refusal is byte-identical to any unauthorized call on the runtime, whether the key exists, doesn't exist, or never did. Fail closed, no oracle — a probing client learns nothing from being refused. Only after auth does a missing key become an honest 404, because at that point you've earned a real answer.

The escrow side completes the picture: one fresh key per sealed entry — so revoking one doesn't unlock the rest — and revocation is a delete from the table. The source calls the design a dead-man switch, and names what it is not: not obfuscation, not a forked format, not a client-side guard — all bypassable, because the attacker owns the client and the bytes. The real gate is a key the client lacks.

which posture is YOURS?

The rubric is shorter than the theory. Ask one question: what must stay private — nothing, the data, or the existence?

your workbookposturewhere it can publish
a demo, a report, a brand book — share-with-anyone is the pointpublicanywhere — static hosts included
a free shell over paid or private data — try the app, auth for the numbersgated_dataself-hosted · desktop-app
an internal tool whose existence is privategated_routeself-hosted · desktop-app

And the price tag, stated plainly: a gated workbook needs a reachable runtime. The hand-it-to-anyone story still applies to the shell — a gated_data shell is a real public page — but the gated parts go inert without an engine answering per-request. That's not a limitation snuck in beside the feature; it is the feature. The only way the file can't leak the secret is if the file doesn't have the secret, and then something else must.

One contrast to keep the vocabulary clean: a posture answers who may read this. Provenance — who made it, and whether it's been touched — is a different question with a different mechanism, the signatures story. A workbook can be public and signed, or gated and unsigned; the two axes don't trade.

why the default is PUBLIC

depth rung · skippable — the fail-open objection, answered

Look up the posture of a workbook that never declared one and you get public. Every security instinct you have just flinched — fail-open is wrong, defaults should deny. The module's own doc makes the counter-argument, and it's worth making rather than hiding: an unregistered workbook carries no server-side secrets to leak. It is its static self — everything it has is already in the file, which is the parent lesson's whole deal. There's nothing behind the runtime to protect, because nothing was ever put behind the runtime. A workbook becomes gated the only way it can mean anything: by explicitly declaring it, at which point the invariant takes over and the static paths slam shut.

Fail-open would be wrong if the default could expose something. It can't — declaring the posture and possessing the secret arrive together.

Belt and braces, the publish validator also sniffs for the mistake the default can't cover: a secret pasted into publish.org itself. Property keys ending in TOKEN, KEY, SECRET, ACCOUNT, PASSWORD, or CREDENTIAL, and values that look like long hex strings, draw a warning: use an env var instead of storing it in publish.org. The posture system keeps secrets out of artifacts; the warnings keep them out of the config that travels with the project.

where the wiring ENDS

Honesty section. Four edges, stated plainly.

The per-route registry is a follow-on. The judge exists, is tested, and is live where it matters most — the key release and the publish pipeline both call it. But wiring every serving route to a per-workbook posture registry needs workbook metadata to carry the posture everywhere, and that wiring is still in progress. The contract and the primitive are real; universal coverage isn't yet.

gated_route is the least-wired stance. Its contract is exact — even the shell requires auth, and the judge enforces it in one line — but of the three postures it has the least end-to-end serving path behind it today. Treat it as a declared contract with an enforcement primitive, not a finished feature.

A locked deploy is a shared secret, not identities. WB_PUBLIC_BEARER gives you one credential for one tenant — everyone holding it is the same caller. As the auth cascade reads, a locked deploy accepts the shared secret, not JWTs — lock-mode and per-user JWT mode are different deploys, not layers of one. If you need to know which user opened the gate, you need the JWT rung.

Gating binds you to a runtime. Said once more, because it's the honest cost: public workbooks are open anywhere forever; gated ones are alive exactly as long as a runtime answers for them. Real gates need a gatekeeper with a power supply.

questions people actually ASK

Can't I just hide the data with JavaScript?

You can write the code, but it isn't a gate — the data and the check ship in the same file, and View Source reads both. The system's word for this is theater, and it refuses to participate: aim a gated posture at a static host and the validator fails the build before the leaking artifact exists.

What does someone holding my gated bundle actually have?

Ciphertext and key references. The sealed entries are AES-256-GCM with the keys escrowed at the runtime; the manifest names which key unlocks which entry, never the key itself. The useful thing they hold is an invitation to authenticate.

Does x-tenant: alice in dev unlock gated data?

No. The dev fallback produces the one identity the judge explicitly rejects — gates require a real, authenticated user, and the test suite pins it. Development convenience gets you a tenant to work in, never a pass through a gate.

Can I change a workbook's posture later?

Yes — it's a declaration, one metadata line, and the guards re-read it on the next build and the next request. One arrow of time though: going public-to-gated protects what hasn't shipped yet. Bytes already published into static artifacts were public when they left, and no declaration recalls them.

What's the difference between a posture and a sealed entry?

Stance versus mechanism. A posture is the declared decision — who may read what, and where bytes may live. A sealed entry is one enforcement mechanism under it — ciphertext in the artifact, key in escrow, released only when the judge allows. The posture decides; the seal carries the decision into the bytes.

Why is there no per-file posture inside one workbook?

There is a finer grain, but it's the seal, not the posture: a mostly public bundle can carry specific entries as ciphertext, each with its own revocable key. The posture stays per-workbook because it answers a routing question — what may this artifact contain at all.

keep GOING

Postures are the decision layer. The layers above and below each have their own lesson.