Version. 0.1
Date. 2026-07-17
Author. Claude.ai, on Operator direction.
Status. Build specification. Filed as CR-2026-153 — the next in sequence, confirmed against the record's highest existing numbered CR (CR-2026-152) at filing time, 2026-07-17.
Source. scoping-notes/loomworks-operator-tools-menu-scoping-note-v0_3.md. Scope is fixed by that note; this document specifies the build.
Grounding. Three investigations, all 2026-07-17: loomworks-stele-agentic-identity-investigation-v0_1, loomworks-authenticated-marketing-access-investigation-v0_1, loomworks-operator-tools-menu-pre-cr-verification-investigation-v0_1.
Technical consumer. Claude Code on DUNIN7-M4.
Targets — three repositories. This is unusual and is the reason for the build sequence in section 8.
| Repository | What changes |
|---|---|
| loomworks-engine | The mint endpoint, a settings field, a key-generation script |
| loomworks-marketing | The entry point function, the verify helper, gating on the sales tools |
| loomworks (Operator Layer) | The menu surface |
What this builds. A menu inside the Operator Layer listing the tools the Operator uses. The Operator signs in with Stele, as they already do. The menu asks the engine for a signed pass, hands it to the tool on the Operator's behalf, and the tool checks it before doing any work.
Why. The sales perspective generator has no gate. The gate designed for it was Cloudflare Access — another company's identity system, in front of a company that builds identity systems. The Operator's standing direction is that anything authenticating a person in a DUNIN7 system uses Stele.
What is new. Four pieces: the menu, an endpoint that mints a pass, a function that receives it and turns it into a cookie, and a check in each tool. Everything they depend on already exists and works.
What the Operator must do that Claude Code cannot. Generate the signing key, put the seed in the engine's environment, and put the public key in the marketing site's Cloudflare settings. Section 9.
Out of scope. Agents. Per-tool authorisation. Revocation. Key rotation. The public Example Generator, which is public by design.
Non-negotiable. A build that violates any of these is wrong regardless of whether it works.
functions/api/_lib.js in the marketing repository is not modified. The public Example Generator depends on it. Change Request 2026-152 section 2 forbids it, and that rule holds here. Anything shared goes in functions/api/sales/_lib.js or a new sibling.~/stele is a dependency. Its contract is "Stele resolves who; the host gates what." Everything this Change Request builds is the host's business, on top of a resolved session. Nothing here belongs in Stele./example-generator/, /api/analyze, /api/illustrate, /api/foundation-draft are unchanged and remain reachable with no pass. A stranger describing their work is the point of that page.
A script, not an endpoint. scripts/generate_tools_signing_key.py in the engine. It generates an Ed25519 keypair and prints both halves:
It prints. It does not write to any file, does not store anything, and does not touch the database. The Operator places both halves by hand.
Why a script rather than an endpoint exposing the public key. An endpoint is a route to maintain, a thing to secure, and a runtime dependency between two deployments that this design otherwise avoids entirely. The public key changes when the key rotates, which is never in version one.
One new field on Settings in config.py: the base64-encoded Ed25519 seed, single-line, following exactly the pattern loomworks_secret_key uses.
Verified 2026-07-17: the environment path carries a base64 seed unchanged. It does not cleanly carry a multi-line PEM — there is no normalisation helper and no precedent for multi-line environment values, and the key-rotation parser splits on commas, which base64 never contains. The seed is the shape the mechanism accepts. Do not introduce PEM.
Unset means the mint refuses. Fail closed.
POST /tools/pass, in a new router. Two dependencies, both existing:
get_current_principal — resolves the session cookie, enforces that the second factor was completedrequire_founder — compares against the stored platform-founder identifier, fails closed if unset
Verified 2026-07-17: require_founder has exactly three consumers, all credit-admin routes, and is a pure read-only check with no writes, no nonce, and no shared mutable state. A fourth consumer disturbs nothing.
Request: the tool being asked for, by name.
Response: the pass, and its expiry.
Refuse an unknown tool name. The set of tools is a constant in the engine. A pass is minted for a tool that exists or not at all.
A signed statement, not an encrypted one. Ed25519 over a canonical payload, using the cryptography package already installed.
The payload carries: the person's identifier, the single tool it is good for, an issued-at time, and an expiry. Thirty minutes.
One tool per pass. A pass for the sales generator cannot open the FORAY generator. This is deliberate and is why the menu mints per tool rather than once.
Canonicalisation is part of the contract. The signer and the verifier are in different languages, in different repositories, written at different times. Specify the byte sequence being signed explicitly and do not rely on either language's default serialisation. A mismatch here produces a verify failure that looks like a key problem and is not.
The engine mints no asymmetric signature today. Its only use of cryptography is symmetric Fernet. This is new code.
A new function on the marketing site, outside /api/. It is not a JSON endpoint; it receives a browser navigation.
onRequestPost. It:
The cookie: HttpOnly, Secure, SameSite Lax, path scoped to the tool, lifetime matching the pass's own expiry. Its value is the pass.
Verified 2026-07-17: setting a cookie in response to a cross-site form POST is allowed — SameSite governs when a cookie is sent, not when it is set. The cookie then rides the top-level redirect and every subsequent same-origin call.
The implementation detail that must not be discovered late: Response.redirect() returns an immutable response whose headers cannot be extended. Setting a cookie and redirecting requires constructing the response directly:
new Response(null, { status: 302, headers: { Location, 'Set-Cookie' } })
The marketing project has no example of this, because it has never set a cookie or returned a redirect at all.
One verification path, used in two places — the entry point before it sets the cookie, and every tool endpoint before it does any work. Write it once, in functions/api/sales/_lib.js or a new sibling. Not in the shared _lib.js.
It checks: signature valid against the public key, not expired, and naming this tool.
crypto.subtle.importKey then crypto.subtle.verify, no library.
Verified 2026-07-17: the marketing Pages project's compatibility date is 2026-05-10 on both production and preview, with no flags. Ed25519 is supported at that date with no flag and no bump. The project imports no cryptography today; this is new.
The public key arrives as a Pages environment variable, the same mechanism ANTHROPIC_API_KEY uses.
Every sales endpoint gates. /api/sales/analyze, /api/sales/shape, /api/sales/render, /api/sales/draft — all four read the cookie and verify before anything else, ahead of the rate limiter and ahead of any Anthropic call. A refused call spends nothing.
Refusal is a refusal, not a redirect. The tools do not know how to authenticate anyone and must not learn. A refused call says the pass is missing or expired. It does not send the browser anywhere.
A new route in the Operator Layer. It is behind the same Stele sign-in as everything else there; it adds no authentication of its own.
Data-driven from the start. The tool list is data, not markup. A new tool is a new entry. This is the whole reason this is infrastructure rather than a one-off gate — a FORAY generator is in design and inherits this.
Each entry carries: what the tool is, in plain terms, and a form that POSTs to that tool's entry point with the pass in a hidden field.
The menu mints on render, one call per tool listed, and embeds each pass in its form. A reloaded page is a fresh set of passes.
A tool the Operator cannot get a pass for does not appear. Not disabled, not greyed — absent. If the mint refuses, the entry is not rendered. This follows the standing rule: only show what is available.
Where it hangs: the Operator Layer's navigation is a hardcoded component and there is no launcher concept anywhere. Adding an entry to that navigation is the smallest correct change. Do not build a general navigation system.
The three repositories make this unusual. Build from the inside out — the thing nobody has exercised first.
Halt after step 3 and report. The cross-site form POST that sets a cookie is the one mechanism nothing in either project has ever performed. If it does not work, the carry is wrong and the menu is wasted work. This is the cheapest kill point and it comes before any surface is built.
None of this is in version control and Claude Code must not attempt it.
loomworks_secret_key.ANTHROPIC_API_KEY uses.Order matters. The public key must be set before the gated tools deploy, or every call refuses.
Accepted when the Operator can:
/sales-generator/ directly, with no pass, from a private window, and be refused./api/sales/analyze with no cookie and be refused./example-generator/ and /harvest-surge/ and find them working, ungated.Point five is the test that matters. On 2026-07-17 a preview deployment of this tool was reachable by anyone holding the address. That must not be reproducible.
The menu's path to the engine exists because the Operator Layer is running as a development server.
Verified 2026-07-17: the engine is reachable at loomworks-dev.dunin7.com/api/, through a tunnel to the Operator Layer on port 3001, whose /api/ proxy forwards to the engine. That proxy is configured only when the environment is development; a production build emits no rewrites.
The design's premise holds — the menu talks to the engine the way the rest of the Operator Layer already does, and that works today. But building the Operator Layer frontend for production would cut this path, and the way out is not small: api.loomworks.dunin7.com has no tunnel ingress rule, the engine's cross-origin allowlist is empty in production, and the session cookie is host-only and would not reach a sibling subdomain without an engine change nobody has scoped.
This is recorded so that whoever productionises the frontend knows what they would be cutting. It is not a reason to delay this build.
Section 8 of that Change Request specifies a Cloudflare Access application covering /sales-generator/ and /api/sales/, settled by Operator direction on 2026-07-17.
Under this build the pass is the gate, and that Access application is redundant — a second door, using the identity system this work exists to stop reaching for. When this lands, CR-2026-152 section 8 needs amending to say the sales generator is gated by the tools pass, with the Access decision preserved as the superseded prior.
That amendment is not in this Change Request's scope. It is named here so it is not discovered later.
functions/api/_lib.js is unmodified, by diff against main.~/stele is unmodified, by diff.DUNIN7 — Done In Seven LLC — Miami, Florida CR-2026-153 — Operator tools menu and authenticated edge access — v0_1 — 2026-07-17