# Nacre — core A self-hosted knowledge index with fine-grained access control. Agents reach it over MCP, applications over a REST API. Apache 2.0; the commercial modules live in a separate private repository. **Read [docs/authz.md](./docs/authz.md) before writing anything.** Every other subsystem depends on the permission model, and reworking it after search exists is expensive. Work order is in [docs/README.md](./docs/README.md). ## The six invariants Breaking one of these is a security incident, not a bug. They are not negotiable in a PR. 1. **The organization comes from the token** — never from a body, path, or header. 2. **Access filtering is a pre-filter, never a post-filter.** The filter goes inside the index traversal so `top_k` returns k *permitted* results. 3. **A failure to evaluate permissions denies access.** There is no "couldn't compute it, let it through" path. 4. **"No permission" and "no such object" are indistinguishable** — `404`, never `403`, including the wording of the message. 5. **A deleted document is never returned**, including before garbage collection. `deleted = false` belongs in every query. 6. **`write` does not imply `read`.** `admin` implies both. This is the opposite of most permission systems; do not "fix" it. ## Never, in any change - Filter search results after they come back from the vector database. - Accept `org_id` from a request body, path, or header. - Return `403` where the object is invisible. - Log document contents or full query text. - Ask for a larger `top_k` and trim **on permission** — that is a post-filter that also costs more. Trimming on relevance over an already-permitted candidate set is what reranking is, and it is allowed; the test is whether the answer can end up smaller than the number of permitted matches. - Skip the ACL filter in one branch of a hybrid query. Every prefetch branch carries it; one omission is a leak. - Query outside `withOrg` without saying which mechanism permits it. There are two, and only two: `whileAuthenticating` for resolving a credential, and `acrossOrganizations` for the worker's queue. A raw `pool.connect()` that reads a tenant table is a query that works in development and raises in production, because development connects as a superuser and the policies do not apply to one. ## Commands ```bash pnpm install pnpm lint # eslint pnpm lint:openapi # the REST contract, which runs ahead of the code pnpm typecheck # covers tests and config files, which the build does not pnpm build # tsc -b across the workspace pnpm test:unit # everything except authz pnpm test:acl # authz only — its own CI job on purpose ``` `test:acl` never passes with zero tests, at any level. If it reports green having run nothing, that is the bug. ## Layout ``` packages/core data model, permission resolver, shared types authz/ the resolver. Modules sit at the package root, not under src/ migrations/ SQL, forward-only packages/mcp MCP server (Streamable HTTP + STDIO) packages/api REST API and authorization service packages/worker indexing pipeline: parse, chunk, embed packages/sdk TypeScript SDK packages/cli the `nacre` command, over the SDK packages/admin community admin UI, single organization services/parser Python sidecar: bytes -> {text, blocks, metadata} deploy/helm the Kubernetes chart, versioned with the release docs/ specifications. These are normative, not descriptive operations/ runbooks: restore, key rotation, reindex rollback, backlog ``` `packages/core/authz` is a directory inside `@nacre.work/core`, **not** a workspace package. Do not add a `package.json` to it. ## State of the code It runs. The whole loop has been driven by hand against a real PostgreSQL and a real Qdrant: `init` creates an organization, `/v1/layers` and `/v1/grants` set up access, ingest goes through the worker to the index, and search returns what the caller is permitted to see and nothing else — over REST, over MCP Streamable HTTP, and over MCP STDIO alike. Revoking a grant drops the document from results on the next search while its vectors are still in the index — the permitted set is computed per request, so there is nothing to wait for. Deleting a document takes it out of results immediately — the points are flagged before the row is written, in that order, because the reverse fails unrecoverably. A collector reclaims them afterwards and nothing depends on when it runs. A worker that dies mid-document has its claim reclaimed by a lease rather than leaving the document stuck in `parsing` forever. `packages/sdk` is the TypeScript client and `packages/admin` is the community admin UI; both are written, and the admin UI has been driven in a browser against the running API. **There is a `nacre` command now**, and the reason it exists is that `docs/quickstart.md`'s path from a clean clone to a first search was four `curl` invocations with hand-assembled JSON, a workspace id copied out of `init`'s output and a token good for an hour. The documented metric — under thirty minutes to a first search — was never measurable because nobody could plausibly be asked to do it twice. It is a thin wrapper over the SDK, so it holds no second idea of what the API is: `login`, `whoami`, `layers`, `layers create`, `grant`, `ingest` (with `--watch`), `search` and `eval`. Three defects came out of running it, none of which any suite could see. The password prompt **printed the password** — readline with `terminal: true` echoes what it reads, and the private `_writeToOutput` interception did not hold on a pipe, which is the path a script and a demo both take. An ingest where **every document failed exited 0**, so a nightly pass checking the exit code would have reported a healthy corpus with an empty index. And `search` returned an **empty document id** through the SDK and therefore through the admin UI: the client read `hit.document_id` where the contract and the server say `doc_id`, and `?? ''` turned the miss into an empty string, so a result could not be turned into `documents.get(id)`. That test's fixture also said `document_id` — it proved the mapper against a response no server sends, which is the shape a fixture written to match the code always has. `nacre eval` scores a layer's reference queries from outside the worker — recall@k, averaged per query, `--floor` to make it a gate. Writing it found that **`external_id` was write-only**: ingest is idempotent on `(layer, external_id)` and no response carried one, so a client could name a document and never ask about it by that name again, and nothing outside the worker could score recall at all. It is on `Document` now, in the contract, the handler and the SDK. `--watch` never deletes, and that is not caution. A file disappearing is indistinguishable from the first half of how every editor saves — write a temporary file, rename it over the original — so removing a document on an unlink event would delete documents on save, intermittently, depending on the editor. Adding the package is also what found `lint:config` naming three files. It read `packages/core/config.ts` and two entry points, which was right until there was a fourth package: `NACRE_API_URL` and `NACRE_TOKEN` were read by shipped code and documented nowhere. The same defect as `NACRE_PARSER_ALLOW_PRIVATE_URLS`, which that check missed because it knew only about TypeScript and the sidecar is Python — and the same repair. It discovers its readers now, and went from three files to fifty-nine variables. The SDK reaches **every** operation in `docs/openapi.yaml` now, and `packages/sdk/src/__tests__/coverage.test.ts` is what keeps that true — it had fallen nine operations behind with nothing comparing the two, which is the same shape as a variable accepted and never read. Adding a path to the contract and not to the client fails, and the fix is a method or a written reason; the two `/.well-known` documents are the written reasons. It asks the other direction now, and that is what found `/v1/embedding-providers`: served by the API, reachable from the SDK, and absent from `docs/openapi.yaml`, which is normative here. Every assertion in that file started from the contract, so a client reaching *more* than the contract describes was invisible to all of them — the same defect as an SDK falling behind, arriving from the other side. The `EmbeddingProvider` schema was already in the document, referenced by no operation, so the route to a second embedding model was the one thing a self-hoster could not find by reading the contract. Which is the reader this product has. The admin UI signs in with an email and a password. It said "there is no login yet" in three places and asked an operator to paste a JWT that expires in an hour — months after sign-in landed. A session renews itself through the SDK's `fetch` seam rather than a wrapper around fourteen call sites, so no view knows it can be renewed, and signing out revokes the refresh token instead of only forgetting it. Pasting a token is still offered, because signing in as a service account is how an administrator checks what an agent can actually see. Moving a layer onto a different model is on that screen now too, with the reference query set beside it — until then the recall gate could only be set up by hand against the API. Two things there were found by looking at a screenshot rather than by a test. The progress bar rendered **full for every migration**, because the page's CSP is `style-src 'self'` and the browser dropped the inline `style="width:…"`; it is a `` now, which puts the width in the stylesheet and brings its own ARIA. And "No recall gate" sat directly above a saved reference query, because `check: null` means both "no set" and "not scored yet" and only the second was true — the panel already loads the set, so it can tell them apart and now does. **The console has been rendered at 1440 and at 390**, every view and every dialog, and four defects came out of it that no suite could see — three of them only on a phone, which is the width nobody develops at. `.row` aligns `flex-end`, which aligns **margin** boxes: `.field` carried `margin-bottom` and a bare button in the same row carried none, so every row mixing the two hung the button exactly that far below the control beside it. The action column wrapped, because five of the six action cells used `.right` and the sixth used `.row-end`, and only `.row-end` said `nowrap` — three actions on one row stacked onto three lines and took a People row from 58px to 153px. The `copy` control beside every id was the word `copy` at 39×19, under half a touch target, and it was `opacity: 0` until its row was hovered — so on a phone, which reports `hover: none`, it was invisible with no gesture that revealed it, and `shortId` truncates the id it copies. Every id on those screens was unreachable on the device half of them are read on. `lint:admin-layout` is the repair rather than the four edits: a flex container aligned on an edge has children with no margin on that edge, and a control revealed on hover is behind `@media (hover: hover)`. Its first version **passed with the defect restored**, and that is the part worth keeping. It looked for a rule whose selector carried the margin under the container — `.row > .field { margin-bottom: 12px }`, the spelling the fix had just removed — while the margin actually lives on a bare `.field` and only the cancellation kept it off the row. Deleting one line brought the crooked buttons back and the check stayed green: it had learned the shape of the edit rather than the property. The cancellation is `> *` now instead of naming a class, which closes the same hole one step later, and the check requires that reset on every edge-aligned container. Six breaks, including the two that defeated the first version. **A third rule joined it, and it is the action column's defect one cell over.** "204 days ago" is three words and one value, and a table cell will break it across three lines — measured in Chromium at 390, a People row carrying that string was **89px** while the rows beside it reading "7 days ago" and "1 day ago" were 66px, and all three are 58px now. The column is as wide as its widest entry, so the oldest row deforms only itself, which reads as a rendering glitch rather than as a wrapped word. Service accounts had it twice in one row. `ago()` is rendered into a `` in three views and five places, every one of which had to remember, with nothing that knew there were five — so the repair is `agoCell`, which writes the class, and the check asks both halves: that the class actually nowraps, and that no view builds such a cell by hand. It takes the cell's other classes rather than assuming them, because three of the five are `muted` and two are not, and a layout fix that recolours two views is a layout fix nobody asked for. What it deliberately did **not** do is chase the rest of the height. A service account row is still 66px because `support-agent` breaks at its hyphen, and a connection's row is 156px because one cell holds a sentence. Both are content of unbounded length, where wrapping is the correct behaviour and `nowrap` would buy a row height with a horizontal scroll. The rule is for values that are one thing. Two things that were not layout came out of the same pass. `lint:tokens` was named in `admin.css`'s own header — "Nothing here invents a hex value, and `lint:tokens` fails the build if one appears" — and **did not exist**, in `package.json`, in a workflow or in `scripts/`. The property held, which is what made it invisible; a claim that a check exists is worse than a comment asking for one, because a reader stops looking. It exists now. And `scripts/screenshots.mjs` could photograph the wrong screen without saying so. It had no `GET /v1/me` fixture, so `me()` got the 500 an unstubbed call gets, the console left the caller a member, hid every admin-only route and fell back to the first allowed view — a run asking for `#/grants` writes a picture of Search over `grants.png` and carries on. It had recorded the missing fixture as a failure, and then died on a control that was not there before printing it, with the images already on disk. **The committed images were never wrong**, and the first version of this paragraph said they were. That claim came from running `md5sum` on the working tree *after* a failed run of this script had already overwritten two of them — measuring damage this session caused and attributing it to the repository, without checking `git show origin/main:` first. The three hashes differ there and always did. A defect blamed on the tree is a claim about the tree, and the place to check one is the tree. The guard is that a shot asserts it is a picture of the screen it is named after, read from the **rendered nav** and not from `location.hash`: the router deliberately leaves the address alone so a member's bookmark survives, which makes the hash a check that can never fail. It throws rather than collecting, because by the time the summary prints the wrong picture is on disk. **And the redaction that change added went on one of the two surfaces.** `/v1/jobs/{id}` and `ingest_status` put the stored failure through `withoutHosts`; `GET /v1/documents/{id}` handed back `documents.error` verbatim, and so did `get_document`, an MCP tool resolving `read` — which is reached by a delegation, so its caller can be a third party an operator connected. That is the exact caller `classifyIngestFailure`'s own header names, receiving `http://embedder.internal:8080/embeddings` through the other door. The most repeated defect here, committed by the change that wrote the cure. The test that pinned it asserted the raw string, which is what a test written beside the code always does. `lint:stored-error` asks every surface that returns that column, and refuses if it finds none — a check with nothing to hold must not report green. **And the redaction itself held for the example and not for the deployment.** The rule needed a dot and a two-letter tail, so it covered `embedder.internal` — written in that module's own header — and none of the service names this product ships with, every one of which is a single label: `embedder`, `qdrant`, `parser`. The way one survived is the lesson: the URL *was* removed, and undici then appended its cause verbatim, leaving `getaddrinfo ENOTFOUND embedder` at the end of an otherwise clean sentence. IPv6 was untouched entirely. A host is recognised by **where it can be** now — after a scheme, in brackets, as `name:port`, after a DNS or socket code, after the phrases this repository writes itself — because those are positions a machine puts one in, and a token's shape is shared with everything a person wrote. Eleven strings this stack actually produces are pinned as tests, and the six that must survive beside them. Over-redaction stays in one place and is written down rather than fixed: `contract.pdf` and `example.com` are the same string with a different tail, a list of extensions goes stale, and of the two ways to be wrong only one leaks. **And the two transports answered `tools/list` with different objects.** `permission` is this repository's own bookkeeping — MCP's `Tool` has no such member — and Streamable HTTP stripped it while STDIO returned it, for the whole life of both. `transport-parity.test.ts` exists against exactly that and was green, because its case compared the tool *names*: a parity case whose projection is narrow enough is a parity case that cannot fail, which is the same defect as reading `location.hash` from a router that never rewrites it. One function both transports call, and the case compares the keys. **Two more were sitting beside it, and the widened case still could not see them.** `capabilities` was compared by no case at all, so Streamable HTTP answered `{ tools: {} }` and STDIO `{ tools: { listChanged: false } }` — one server telling two clients two different things about what it supports. And the `tools/list` case projects `r.tools`, so the *wrapper* went unasked: HTTP carried `ttlMs` and `cacheScope` and STDIO carried neither, leaving a STDIO client with no cache hint for the one result here that is per caller. Each transport's own suite was green, because this file asserted this server and that file asserted that one, with nothing asking whether they were the same server. So the repair is `results.ts` rather than a third pair of edits: `initialize`, `server/discover` and `tools/list` are the protocol's results and the transport is not one of their inputs, so they are built once and both dispatchers return what they are given. A divergence now needs a second call site. `listChanged: false` is the unification because both readings are correct in the specification and only one of them is a *statement*; `ttlMs` and `cacheScope` go on both because `server/discover` already carried them on both, which is what makes the asymmetry an oversight rather than a decision. And the parity suite compares the **whole result** now, field for field, for every method in its table — the slices stay for their failure messages. A field that must genuinely differ belongs in an exemption with its reason; there is none. Both divergences were restored afterwards and the new case caught each while every slice stayed green, which is the measurement that says the slices were never going to. **A browser could never reach the MCP transport, whatever the allow-list said.** `Origin` was validated — a MUST, and the DNS-rebinding rule the specification names — but validating an origin and *admitting* one are two halves, and only the first was built. There was no preflight handler and no `Access-Control-Allow-Origin` on any reply, so `NACRE_MCP_ALLOWED_ORIGINS` could turn a `403` into a response the browser then discarded. `docs/config.md` said to set it "only if a browser talks to this transport directly", which nothing could do. Found by sending a preflight at a deployed stand rather than by reading: `403 Origin not allowed`, with no `Access-Control-*` header on it. The same shape as a variable accepted and read by nothing, one step along — read, and insufficient. `WWW-Authenticate` is exposed, and that is the part worth keeping: a browser client reads the RFC 9728 pointer out of the `401` and cannot begin discovery without it, so a transport that admits the origin and hides that header has admitted a client that can only ever be unauthorized. Credentials are never allowed — the token is a header and never a cookie, so credentialed CORS would buy nothing and would let a page on an allowed origin act as whoever is signed in there. **Nothing changes with the list empty**, which is the default and what every existing deployment has: no header is emitted and a preflight is refused exactly as before. A case asserts that, beside the four that assert the browser half, and each was checked by restoring the defect. **The API needed the same half**, and that is why the implementation is one module rather than a second copy. A browser MCP client reads the `401`, finds the authorization server, and then registers and exchanges its code on the *API* — both by `fetch` from a page on another origin — so a transport that admits a browser while the API does not is a walk that stops one step after it starts, with the failure in a browser console and in nobody's log. `NACRE_API_ALLOWED_ORIGINS` is that list, empty by default because the admin console is served from the API's own origin and has never needed one. `*` is refused at startup on either list. Nothing treats it as a wildcard — an origin is admitted by exact match — so it would be a list matching nothing while reading as one that opened the surface to everybody, and on an authorization boundary that is the dangerous direction to be wrong in. Checked by running `loadConfig` three ways: `*` refused by name, a real origin accepted, unset empty. The first version of that also shipped a `parseAllowedOrigins` nothing called, because the config reader has its own list parser — an exported function with no caller, in the same commit as a check about variables read by nothing. Deleted rather than wired up. **And the two surfaces then wrote their own header lists, so the API refused the one header every MCP client sends.** `Access-Control-Allow-Headers` was a string literal on each side; the MCP transport's had `mcp-protocol-version` and the API's did not — and the API is what serves both `/.well-known` documents a browser MCP client reads. The preflight admitted the origin and refused the header, so the browser cancelled discovery before it left. Nothing failed. The MCP SDK retries discovery without the header, so the walk finished and what a deployment saw was two `net::ERR_FAILED` lines in a console and a flow that worked anyway. A client that does not retry gets no metadata at all, which is the same "admitted a client that can only ever be unauthorized" shape that put `WWW-Authenticate` in the exposed list one paragraph up — arriving on the request side. The shared set is a constant both surfaces build their list from now, each adding only what it alone reads, and the two suites ask it from that constant rather than spelling it out — a case that names its own headers is a case that passes on the ones the surface kept. Found by driving a deployed page in a real browser and reading the failed requests' *headers*: `curl` gets a `200` from both documents, because `curl` sends no preflight. Rate limiting, `Idempotency-Key` and cursor pagination are in, which is also what Redis is finally for — it had been required configuration, and in every Compose profile with the API waiting on its healthcheck, since before anything connected to it. Both fail **open**, deliberately and against the grain of invariant 3: neither is an authorization control, and failing closed would turn a cache restart into an outage. **Search is hybrid, which it had only ever said it was.** `docs/architecture.md` described "dense vector plus sparse BM25, fused with Reciprocal Rank Fusion" from before there was a server, `collectionConfig` created every collection with a `bm25` sparse slot, `buildHybridQuery` accepted a `SparseBranch` and the ACL prefilter suite passed one through — and **nothing ever produced a sparse vector**. Not the worker on ingest, not the search path. The slot was empty on every point of every collection and every query was dense-only, for the entire life of the project. Nothing could have failed. A slot with no writer is not an error at ingest; a branch never built is not an error at query time; and a test that hands the builder a sparse branch proves the builder, not that anybody calls it. Every piece was individually correct, which is why this is the same shape as `NACRE_ACL_CACHE_TTL` and the propagation gauge rather than a new one — declared, accepted, read by nothing. `packages/core/text/bm25.ts` is the missing producer and it is one module because the two sides have to agree on every token: a term hashing one way at ingest and another at query time is a branch that never matches, silently. The document side writes term frequency only and IDF is Qdrant's, from `modifier: 'idf'` on the slot — an IDF stored at ingest freezes each chunk's idea of how rare a word is at the moment it was written. No stopword list, because IDF already scores a ubiquitous term near zero without one to maintain per language; no stemmer, because it needs language detection and works against what this branch is for, which is the class of term dense retrieval handles worst: error codes, contract numbers, `NACRE_*` variables, surnames. An identifier is indexed whole and in parts, so `s3` finds a chunk that only writes `NACRE_S3_ENDPOINT`. One lexical branch however many models the organization runs, and no `onlyLayers` on it: BM25 has no model, so confining it would drop points the caller may see for no reason. `lint:sparse` is the repair rather than the two edits — every slot the collection declares has to have a producer on each side, which is the question none of the individually-correct pieces was asking. Driven against a real Qdrant, because the parts that were left were the database's: `hybrid-live.test.ts` asserts the slot and the modifier, the point carrying both vectors, the identifier ranking first, the lexical branch not reaching an excluded layer, Cyrillic, and a pre-upgrade point with no sparse data failing to match rather than erroring the query for everybody else. The IDF case is the one worth keeping, because it failed first and the failure was the test's. It asserted that a rare term outscores a common one over a corpus where both had a document frequency of two, so what it measured was length normalisation — 1.4008 against 1.4084 — and it would have been just as green if the modifier did nothing. A claim about IDF needs a control: the same points in a second collection whose slot has no modifier, and a corpus where term frequency alone favours the *common* term. Raw, `access` wins; with the modifier, `sqlstate` does. Reranking is on the search path, off unless a deployment configures a reranker. It fetches `NACRE_RERANK_CANDIDATES` from the index and returns the best `top_k`, which is **not** the over-fetch invariant 2 forbids: every candidate has already passed the permission filter inside the index traversal, so the trim is on relevance and changes which permitted results come back, never how many. See `packages/api/src/rerank.ts` — the argument is in the code because the next person to read the search path will see the widening and reach for the rule. **And the reranker the profiles ship refused every batch the product sends.** `HttpReranker` posts the whole candidate set in one call — `NACRE_RERANK_CANDIDATES`, 50 by default and 500 at most — and Text Embeddings Inference's `--max-client-batch-size` defaults to **32**. So `full` and `airgapped` answered `413 batch size 50 > maximum allowed batch size 32` to every search with more than 32 candidates, from the day reranking landed. Nothing failed, and that is the whole difficulty: reranking fails **open** by design, so a search whose reranker refuses degrades to fusion order with a counter and a log line. A deployment that configured a reranker got searches that answered, ranked by fusion, forever. The embedding batch defect exactly — declared, wired, and refused by the server at a bound nobody had asked it for — and found the same way, by sending the shipped default to a real one rather than by reading. **Splitting is the wrong repair and that is not a matter of taste.** A reranker is not promised to score each text independently of the others in the call, so two calls produce two sets of scores that cannot be compared — a wrong *ordering*, with no symptom at all, which is worse than the refusal. That is already written down for the adapter, and it is why `embedInBatches` does not carry over. So the server is told to accept what the product can send. Which makes it two numbers with nothing that knows there are two, and the repair is `lint:rerank-batch` rather than the one-line edit. **Both are discovered**: the ceiling out of `config.ts`'s own validator and the limit out of the compose service's own command, so raising the configurable maximum without raising the server's fails there rather than in somebody's results. Five refusals, each produced — including the shipped state, which names itself. `docs/config.md` carried the sentence that made this invisible: *"the limit is far above anything a search sends"*, true of the adapter's 512 and false of the TEI those profiles run. It says which is which now. **And `HttpReranker` had only ever been asked of a stubbed `fetch`.** Every case in `rerank.test.ts` replaces `globalThis.fetch` and answers with a `Response` the test wrote, so what they prove is that the client agrees with itself — the fixture-written-to-match-the-code shape, on the path where being wrong is silent. `scripts/ci/rerank-live.mjs` asks a real one, in a job of its own rather than in the unit suite, which is arithmetic: four workflows run that suite, so a live case there is four model loads on every pull request. It drives `dist` and not the source, it starts the server with **the flag the compose file passes** — read out of that file, since a second copy of the number is the defect one file over — and it uses a smaller cross-encoder than the profiles ship, on the e2e smoke's own argument: what is under test is the wire and the bound, neither of which is a property of the weights. Two things it says that no stub could. The server answers **sorted by score and not in input order** — measured, `[0, 2, 1]` for three texts — which is why the mapping is by `index` and why trusting arrival would attach the wrong score to the wrong chunk. And the shipped defect reproduces: run against a TEI started the old way, three of its five assertions fail naming the 413 while the two about the contract stay green, which is the signature that says the contract was always right and the bound never was. Email and password sign-in is in, with rotating refresh tokens: replaying a used one revokes the whole family, because the legitimate holder has already exchanged it and there is no way to tell which of the two holders is genuine. Passwords use scrypt at OWASP's minimum, with the parameters carried in the stored record so the cost can be raised later without invalidating every one. Both surfaces are limited by the same buckets: `NACRE_RATE_*` used to apply to REST only, so a client out of search budget could point at the MCP port and carry on. MCP has its own `/metrics` now too — it had none, which made every latency and denial claim in `docs/config.md` true of REST and silent on the transport the product is for. Sign-in is bounded three ways, because the per-address limit alone bounds nothing an attacker actually does: per address, per client (`NACRE_TRUST_PROXY` decides what a client is, and neither default is safe), and by a cap on concurrent scrypt calls — that one holds whoever is calling and whatever Redis is doing, because scrypt runs on libuv's four-thread pool alongside DNS, so an unbounded login endpoint stops the rest of the API on a name lookup. `refresh_tokens` and `audit_events` are pruned. Both were documented as swept and neither was; `NACRE_AUDIT_RETENTION_DAYS` had been validated at startup and read by nothing since it was added. Retention goes through a `SECURITY DEFINER` function that takes a number of days and never a predicate, so it can expire a window past a 30-day floor and can never erase a chosen event — which is what the append-only grant was protecting. Search finally honours `layers` and `include_content`, and **refuses** `filters` rather than ignoring it. All three were in the contract from before there was a server and read by nothing: a client scoping a search to one layer searched all of them and believed otherwise. The access log is readable — `GET /v1/audit`, newest first, cursor-paged, as JSON, JSONL or CSV by content negotiation. `org_admin` sees which documents were read; `platform_admin` sees administrative actions and never that, which is rule 2 applied to the journal and is set by the handler rather than being a parameter a caller could drop. Reading the log is recorded as `audit.read`. A layer can be moved onto a different embedding model. Qdrant cannot add a named vector to a live collection — checked by asking it, not by reading — so the collection is not altered but replaced: a new one carrying both slots, every point copied across unchanged with **no embeddings computed**, the `organizations.vector_collection` pointer switched in one statement, and then re-embedding one layer at a time into the new slot. The cheap half is org-wide and happens once; the expensive half is per layer and incremental. Search stays available throughout and stays one query. It carries one dense branch per model among the layers in scope, each confined to those layers and each embedded by that layer's own provider — the confinement is a `must` appended to the permission filter, never a filter of its own. That work found four things it did not set out to find, all the same root cause: `organizations.vector_collection` was written once at init and read by nothing, so every read and write derived `org_${slug}` instead; the search path took its named vector and its query model from `NACRE_EMBEDDING_MODEL` for every layer in every organization; the worker's ingest path did the same; and `finishReindexIfDone` moved `vector_name` without `provider_id`. Together they meant **an organization could never run two embedding models**, which the schema has offered since 0001 — `embedding_providers.org_id`, documented "NULL = global default". Adding a second provider and creating a layer on it accepted documents and failed every one of them in the worker, forever, while the API answered `queued`. Search filters on document metadata. `metadata` was declared on ingest with no caveat and dropped by the handler, and the worker then overwrote the column with the parser's derived facts — the title bug again, with a tag disappearing rather than a name. It is stored now, written into the payload of every point under a reserved `meta` namespace, and read back by `filters`. Those fields are indexed now, which `PAYLOAD_INDEXES` structurally could not do — it lists the permission filter's fields, and a caller's keys are not knowable in advance. They are built where the keys first appear, on ingest and on the `PATCH`, and carried across when a reindex replaces the collection, which is where they would otherwise have been silently dropped. Bounded at 64 per collection and allowed to fail: a filter on an unindexed field returns exactly the points an indexed one would, so this is latency and never an answer. The namespace is the security property and it is structural, not a check: a caller key can never collide with `org_id`, `deleted` or `doc_id`, because `meta.deleted` is a different field. And a filter is a **narrowing**, the same mechanism `layers` uses — every entry becomes a `must` beside the permission constraint, so there is still no path by which a caller-assembled filter reaches the index. No negation, no ranges, no disjunction across keys: each is a way to widen if composed wrongly, and none is needed to answer "only documents from this source". `PATCH /v1/documents/{id}` changes a document's tags without re-embedding it — one `setPayload` over its points. Going through ingest instead re-parses and re-embeds, because that is what ingest does; the difference is what a bulk retagging pass costs. It answers `204` and never the document, because rule 6 means a caller may hold `write` without `read`. **`/v1/ready` refuses while the schema is behind the image.** It reported that Postgres, Qdrant, Redis and the bucket answer and said nothing about whether the schema matches the code — so a pod started against a database the migrator had not reached reported ready and then failed every request. Under an orchestrator that is worse than an error: the rollout believes the answer and carries on replacing working pods with broken ones. Found by writing `docs/upgrading.md`, which is the second time an operator document has turned up a defect the tests could not see — the first was the Helm chart's provisioning and the superuser-only migrator. A database that is **ahead** stays ready, which is the middle of a rolling upgrade: the migrator has run for the newer build and the old replica has to keep serving. Reporting it behind would take every old pod out of rotation at the moment the schema moved. Migration 0022 is what makes the check possible, and finding that out is the part worth keeping. `schema_migrations` is created by the migrator and therefore owned by the owning role, and `nacre_app` held **no privilege on it at all** — so a readiness probe written without the grant would have reported every correctly-split deployment as not ready, and worked fine in development, where the connection is a superuser. That is the same defect as the two subsystems found this way before, arriving from the other side. Checked against a real PostgreSQL before a line of the check was written. `SELECT` only: a process that could write the ledger could tell the next migrator a migration had already run. And deliberately not granted to `nacre_worker` — the worker has the same question and no surface to answer it on, so the grant would be a privilege nothing reads, which is the shape this repository keeps removing. **A team can be onboarded.** `grants.principal_type` has admitted `user`, `group` and `service_account` since migration 0001, and only the third could be created through the API — so the documented way to give a colleague access was to insert into `users` by hand, and the documented way to give a team access was to insert into `groups` and `group_members` by hand. The same shape of hole the workspace listing had: the model offers something the product gives no route to, and the route people find instead is `psql`. `/v1/users` and `/v1/groups` close it, at `org_admin` — not "admin on a scope", because a principal belongs to the organization rather than to a workspace inside it, and someone holding `admin` on one layer must not be able to mint one any more than they can mint a key. Migration 0021 is the only schema change and it is small: `groups` and `group_members` had no `created_at`, and every paged collection here seeks on `(created_at, id)`, so neither could be listed by the shared machinery at all. A password is **generated and never accepted**, on `init`'s argument — an argument ends up in a shell history — plus one more: a password an administrator chose is one they know. `POST /v1/users/{id}/password` exists because without it an administrator whose colleague lost theirs had no route that did not go through the database, which is the gap this whole surface is about. `platform_admin` is refused rather than downgraded: it spans tenants in the multi-tenancy module, so minting one from an endpoint scoped to a single organization would be an escalation out of it. A user is **disabled** and a group is **deleted**, and the asymmetry is structural rather than stylistic. The audit log names a user id and `grants.created_by` references `users(id)` with no cascade, so a deleted administrator is a foreign key violation and a deleted anyone is an unresolvable reference in the one record that must not have them. Nothing points at a group that way — but nothing removes its *grants* either, since `principal_id` addresses three tables and is a bare uuid, so deleting one takes them in the same transaction. `DELETE` and `PATCH {"disabled": true}` go through one call, which is what makes the last-administrator guard hold: an organization with no active `org_admin` has no route back, because every endpoint that could appoint one is behind the role that was just given up. Two entry points with one check between them is how the guarded one gets walked around, and the count runs in the same transaction as the update it guards, behind a `FOR UPDATE` — two administrators demoting each other concurrently would otherwise both read a count of two. The refusal that surfaces as `404` for a caller who is not an `org_admin` writes a `deny` event. It is the easiest one to miss, because the code path producing it is an early return that reads like routing. **That surface guarded the role it set and not the role it replaced.** `POST /v1/users` refuses to issue `platform_admin`, correctly and for a stated reason: `/v1/users` is scoped to one organization, the role spans all of them, so minting one there is an escalation out of the scope doing the minting. The argument holds in the other direction with the same force and nothing enforced it — an `org_admin` could take a platform administrator whose account happened to live in their organization and demote them, disable them, delete them, or **reset their password**. The last is not a demotion: the endpoint returns the plaintext, so it is a takeover of the account that administers the installation, performed by somebody who administers one tenant. Four spellings of "act on this person", each of which had to remember, with nothing that knew there were four — the shape this file already names. So the repair is one helper every write to somebody else's row goes through, reading the role `FOR UPDATE` in the same transaction, and `check-platform-admin-target.mjs` refusing a write that does not. It reads the guard's *own body* rather than the file, because the first version passed with the comparison removed: `principals.ts` says `'platform_admin'` in a type and in half a dozen sentences of prose, so a whole-file search was satisfied by the documentation of the rule instead of the rule. The one exemption — the scrypt-parameter rehash on the sign-in path, where the principal is its own target — is written down with its reason and fails the check if the statement it names changes. `403`, in one wording, on all four. Not `404`: `GET /v1/users` lists that person with their role, so the caller is looking straight at the row and invariant 4 is about invisibility. Not `409` either — the last-administrator refusal beside it is about the organization's state and goes away once there is a second administrator, and this one never does. Nobody reaching those handlers is a platform administrator themselves, since `administers(auth)` is `org_admin` and nothing else, so the refusal is about what the endpoint is scoped to and has no branch on the caller. What issues and revokes the role instead is a command holding the database credentials, outside this surface entirely. Found by writing that command, which is the fourth time an operator-facing piece of work has turned up a defect no suite could see. **A layer can be deleted, and it takes its documents with it.** Everything else on the layers screen edits one — there was no way to remove a layer at all, so a slug typed wrong was permanent and the only correction was a second layer beside it. `DELETE /v1/layers/{id}` needs `admin` on the *workspace*, the same check renaming makes and never `write`: an ingest-only service account is exactly the principal that holds `write` into a layer and must not be able to remove it. The index goes first and it is **one** call — a `setPayload` over a filter on `layer_id`, so the cost does not grow with the number of documents — then the document rows, then the layer, then the grants naming it. That order is the document delete's order for the same reason: the reverse leaves a window where the rows say deleted and the index still answers, and invariant 5 is about what a query returns rather than about what is still on disk. The cascade underneath is the collector's, on its own clock, and nothing a caller sees waits for it. Removing the grants is the part that is not about permission. They would resolve to nothing anyway, so no answer changes — what it avoids is `GET /v1/grants` listing rows pointing at a scope no reader can look up. `GET`/`POST /v1/workspaces` and `PATCH /v1/layers/{id}` close the last two paths the contract described and the server answered `404` for. The workspace listing is the one that mattered: creating a layer takes a `workspace_id` and the only way to have one was the line `init` printed, so a second administrator had no route that did not go through the database. Its first implementation reproduced exactly the deadlock it was added to break — `resolve` flattens a grant set to the layers it reaches, so an administrator of an *empty* workspace resolves to `none`, and an early return on that left them seeing nothing. Found by running it. `/.well-known/oauth-protected-resource` is served, by both the API and the MCP transport, from one document built once. Every `401` from MCP had named that path in `WWW-Authenticate` since the transport existed and nothing served it. `authorization_servers` names this installation's **API** by default, or whatever `NACRE_OAUTH_AUTHORIZATION_SERVER` names. Never the MCP transport, which verifies tokens and issues none. It was absent by default until the API grew the consent flow, on the argument that a client sent to a token endpoint that did not exist would find nothing — the argument was right and the endpoint is what changed. **An MCP client connects over the whole chain now, and that was checked by running it** with the real SDK against a running API and a running transport: `401` → the RFC 9728 document → RFC 8414 discovery → RFC 7591 registration → `/oauth/authorize` → the consent screen in a browser → `/oauth/token` → `initialize` → `tools/list`. Two defects only that walk could find, both of them green in every suite. `initialize` counter-offered `2026-07-28` — the newest revision this server speaks — to any client proposing something not on its list, and `2025-11-25` was not on that list. That is the newest revision the MCP SDK knows, so it is what every shipping client proposes: each was handed a revision absent from its own `SUPPORTED_PROTOCOL_VERSIONS` and failed with `Server's protocol version is not supported`. The specification's compatibility matrix has the rule the code was missing — a client arriving on `initialize` is *legacy*, and legacy clients have no fall-forward mechanism, so a counter-offer must be a revision that generation knows. The list gained `2025-11-25` and the counter-offer is now always the newest **legacy** revision. STDIO had the same defect and worse: it answered unconditionally, never reading the proposal. `server/discover`, a MUST in this revision and the modern era's opening move, is served on both. And `/oauth/authorize` sent the browser to a page with no way to approve anything. `NACRE_OAUTH_CONSENT_URL` ends in `#/consent` — the admin UI is hash-routed, so that fragment *is* the route — and the handler assigned the fragment instead of appending to it, so the router saw no route and rendered the default view with the whole request sitting in the URL. The consent screen's own parser reads `#/consent?…`. Two halves written to different assumptions, with nothing putting them side by side. The JSON-RPC envelope also escaped its endpoint: every path the transport does not route answered one, so a client falling back to `POST /register` on the MCP origin got `{"jsonrpc":…,"error":{"code":-32601}}`, tried to read it as an RFC 6749 error and reported `Invalid OAuth error response: ZodError`. The envelope belongs to `/mcp`; everything else answers `{ error, error_description }`. **An application can act as the person who approved it.** OAuth exists so a *person* can let an application act for them, and the consent flow could only offer a service account — both listing and minting one are `org_admin`, so a member who followed an MCP client's link reached an empty picker and a `404` on Approve. Found by looking at a screenshot, which is the third defect that arrived that way. A **delegation** is deliberately not a fourth principal type. Nothing is granted to one, so there is no second grant set and no intersection to compute, and therefore no new way for the resolver to be wrong: `authority(delegation) = resolve(delegating user)`, unchanged. The token carries the person's id and the connection's, never a permitted set — a token carrying one would keep answering with the access its holder had at consent, and every revocation would wait for it to expire. `disabled` gains a second meaning on this path and only on this path. It has meant "cannot sign in" and not "cannot act", which was safe while every authority either passed through sign-in or carried its own `revoked_at`; a delegation is authority derived from a person, held by a third party, and renewed without them present. So every delegated request makes one indexed read before `resolve` — and it is deliberately **not** cached, because the effective-principals cache keys on `groups_version`, which does not move on `users`. Suspended, not revoked: the grant survives, and renewal refuses without *spending* the refresh token, so re-enabling is a restoration rather than a reconnection. A person restricts a delegation in two dimensions, and the second is the one they ask for first: a **permission ceiling**, so connecting a search client does not hand it the ability to delete a document. A set rather than a level, because rule 6 makes permissions unordered — `{write}` alone is an ingest client that cannot read back what it wrote, and a ladder would have lost that case silently. It is applied **before rule 3** in the resolver, which is the whole of T25 rather than a matter of style: an `org_admin` reaches everything by role and by no grant at all, so a ceiling consulted after that line does not bound the one principal it exists for. Checked by moving it after and watching a read-only delegation of an administrator answer `all` on write. **The ceiling is per layer now**, because the two questions the screen asked were independent and what they could express together was their *product*. A person does not mean a product: they mean "read the handbook, write to scratch", and as a rectangle that costs `write` on the handbook. The resolver did not change, and that is the design rather than luck. `oauth_consents.permissions` stays the gate on everything the token may exercise at all, applied before rule 3; a per-layer set is a **narrowing**, the same mechanism `layers` already was. So the layer filter became a function of the permission — `layers(p) = { L : p ∈ ceiling(L) }` — at the `must` inside the index traversal and at each of the four paths where a layer id and a document meet. `withinDelegation` takes the permission as a required argument, which makes every existing call site a compile error until it says which one it means. A per-layer set that the connection's ceiling excludes could never take effect, so consent refuses it naming both sets rather than storing a control that does nothing. And it never confers administration: `admin` inside one layer is not authority over the organization holding it, so `administers` reads the connection's ceiling and never a layer's. `admin` is a ceiling value and is deliberately **not** on the consent screen. The MCP surface had no administrative tool when that was written — its seven tools resolved with `read` or `write` — and the one it has since, writing a layer's skill, gets a narrower box of its own rather than this one, so the box would do nothing where the person is looking and a great deal through REST, where they are not, which is worse than a control that does nothing. It stays reachable through the API because it is not an escalation: a ceiling cannot exceed what its person holds, so only an `org_admin` can obtain one, and that is weaker than the service account they could mint instead — a delegation stops when they are disabled and a key does not. The contract says so, so the screen and the API do not quietly disagree. And it bounds administration separately, because minting a user or a service account is gated on the *role* rather than on a scope. Not by rewriting the role to `member` — that would stop an `org_admin` reading the organization they came to delegate. Role and ceiling stay two facts, `administers(auth)` asks both, and `check-admin-gate.mjs` refuses the raw comparison it replaced so the tenth handler cannot be written the old way. Writing it found two sites and one resolve input that I had not counted. The layer narrowing is a `must` on `layer_id` inside the index traversal, so `top_k` still returns k permitted results — and it is enforced again on every path where a layer id and a document meet, because fetching by id is exactly how a narrowing gets walked around otherwise. `postgresVerification` is the answer to the wiring rather than a third copy of it. Three processes verify tokens and each assembled `VerifyOptions` by hand; a transport missing `delegations` does not crash, it refuses every delegated token with the `401` a forged one gets, so the symptom would have been "this client cannot connect", days later, with nothing in a log. The failure mode is why the ports arrive as one object. The collection a reindex replaces is reclaimed. Every migration used to leave a full copy of the organization's vectors behind forever, and the runbook's manual cleanup was the wrong rule as well as a manual one: "every collection Qdrant has that nothing points at" describes the *target* of a copy still running, so running it mid-migration deleted the migration. Candidates come from a table written by the same transaction that moves the pointer, and the pointer is checked again before each delete — so a collection rolled back onto is dropped from the list rather than from disk. `NACRE_COLLECTION_RETENTION_DAYS` is the rollback window, not a tidiness delay: moving the pointer back is the cheap rollback and it works only while the old collection exists. The superseded *vector slot* is reclaimed too, on the same window as the collection. A completed reindex leaves every point in the layer carrying the vector it used to be searched by — a float per dimension per point, in memory by default — and nothing removed it. Qdrant cannot drop a named vector from a collection's schema, which is the constraint the whole migration turns on, but it can drop the *data* for one from a chosen set of points, which is all that costs anything. Verified by asking it: the slot stays declared, a query on the live slot is unaffected, and a point missing the queried vector does not error — it simply does not match, which is why the selection refuses the slot the layer is searching now. **Client registration is not ours and no longer says it is.** `docs/mcp.md` carried "the MCP server is a resource server, not an authorization server" and, eleven lines later, "client registration is CIMD, DCR is kept as a legacy branch behind a flag" — and CIMD and DCR are both transactions between a client and an *authorization server*. `NACRE_OAUTH_CIMD_ENABLED` and `NACRE_OAUTH_DCR_ENABLED` were removed rather than moved further down the "not built yet" list, which is a different statement: a variable for a role the product has declined tells an operator it has a knob it will never have, and they set it and believe something. The whole OAuth surface a resource server has — the RFC 9728 document and local validation of an audience-bound token — is built. A reindex is gated on recall now, which was step 4 of the migration sequence in `docs/architecture.md` and had been listed as not built since before there was a reindex. Every other step checks that a write happened; this is the only one that asks whether the new model can still answer — and a migration onto a misconfigured provider succeeds at every mechanical step, so the wrong model name behind the right endpoint moves `vector_name` and collapses retrieval with no error anywhere. Recall@10 against documents the deployment picked, averaged per reference query. **Not agreement with the old model**, which would have needed nothing from anyone and would have been the wrong measurement: a better model disagrees with the worse one it replaces, so a gate on agreement blocks the migrations worth making and passes a new model that reproduces the old one's mistakes. The set lives in `reference_queries` and is written whole through `PUT /v1/layers/{id}/reference-queries`; a layer without one has no gate, which is the arrangement rather than an omission. The gate is a predicate inside the switch statement, not a check in front of it — the same reason the completeness predicate is there, so a reference set written while the score was being computed blocks the switch rather than being outrun by it. A stale set **fails without being scored**, because counting a document that is not there as a miss would report a bad reference set as a bad model. An unreachable embedder writes no verdict and is retried, since it says nothing about recall either way. Its retrieval carries `org_id`, `layer_id` and `deleted = false` and no ACL filter, which anywhere else here would be the leak every other rule exists to prevent. The reason it is allowed is structural rather than judged: there is no principal, nothing calls it for a caller, and a ratio is what leaves it. The port takes a layer and a vector and never a filter, so there is no argument through which a caller-shaped query could reach the index. Writing it turned up that `finishCopy` carried the same completeness predicate three times, in three copies that had to agree and nothing made them. A document can be uploaded as a form. `multipart/form-data` was in `docs/openapi.yaml` from before there was a server and listed as not built for just as long, and it is parsed here rather than by a library: this is attacker-supplied bytes on the request path, which is the one place a dependency's parser bugs become ours. The obligation that comes with that choice is strictness — every bound is a refusal and not a truncation, because a truncation is a silent disagreement between what was sent and what got stored. The fields reduce to the same object a JSON body is, which is what keeps T2 holding: `rejectTenantOverride` scans the body before routing, so a multipart request whose fields never became that body would be a second door into ingest with the check on the other side of it — the shape of hole the rate limiter and the metrics each had when MCP became a second surface. The file is deliberately kept out of that object; a document body does not belong in something that gets scanned, logged and put into error messages. **A PDF can be uploaded, and any other binary format is still refused at the edge.** That was an end-to-end change and the estimate was right about which pieces it touched: the parser port grew a third form (`{ content } | { url } | { bytes, contentType }`), the sidecar took its first dependency ever — pure-Python `pypdf`, pinned, chosen for dependency surface because it runs hostile input; **it is `pdf-inspector` since 0.5.8**, and the reversal of "pure Python" is argued in `services/parser/requirements.txt` — migration 0020 added `documents.content_type`, and `content_hash` became `sha256` over the *uploaded bytes* for a binary source while staying over the text for the others. Getting that last one wrong would have been invisible: the API stores the hash when it accepts the file and the worker recomputes it, so a worker hashing the extracted text instead would make every identical re-upload miss the row and re-embed forever. **Both signals must agree, and binary requires object storage.** The part must declare `application/pdf` *and* the bytes must begin with `%PDF-`; either alone is a refusal naming the other, because a declared type the bytes contradict is the disagreement the multipart parser's strictness exists to refuse, and sniffing alone would make the declared type decoration. A PDF's bytes have one home — `documents.source_ref` is text and stays text — so a deployment without `NACRE_S3_*` is refused on the request, naming the variables. The URL path stays text-only on purpose: a response's declared type is an attacker's field. The sidecar decoded with `errors="replace"` until the refusal went in, so it replaced a real corruption rather than a hypothetical one — a 58-byte PDF produced six replacement characters that would have been chunked, embedded, stored as the document body and reported as `indexed`. Proved by running it rather than only by testing it, which is the rule this repository keeps re-learning: each of the four stages passed against a mock of the next one, and mocks agree with whatever they were written to. The compose e2e now generates a real one-page PDF, uploads it to the running stack, drives it to `indexed`, and asserts the extracted phrase comes back out of a search — with a constant-vector stub embedder, so relevance decides nothing and the phrase is there only if it came out of the PDF. **Office and OpenDocument files — text, presentations and spreadsheets — EPUB and RTF are accepted, and the table that admits them is one table.** The PDF path was one format with the string `application/pdf` written in three processes — the edge that admits a file part, the worker that decides whether a stored object is bytes or text, and the sidecar that dispatches on it — which agreed exactly as long as there was one format. `packages/core/formats.ts` is the table now: a declared type, the signature its family begins with, and the name the sidecar converts it under. The edge and the worker read it, and the sidecar's own suite holds its Python copy against the file, both directions, because a row the edge refuses is dead code and a row the sidecar lacks is a document accepted, queued and failed. A signature names a family and not a format. Every Office and OpenDocument file is a ZIP archive and begins with the same four bytes, so what the edge can hold is "a ZIP-based document, declared as one" — which one is the declaration's job, and the sidecar names that format to the extractor rather than letting it sniff, so a `.docx` declaration over an `.odt` archive fails on the parts the extractor then cannot find. That is "both signals must agree" enforced where the archive is actually opened, and it is why `anydoc` is never handed bytes without a format. The sidecar has two dependencies now and the second was **not** allowed to replace the first, on measurement rather than on preference. `anydoc` carries a PDF path built on the same crate `pdf-inspector` is, and over the three hostile shapes `requirements.txt` names it calls two of them scans — a stream declaring four gigabytes and a truncated inline image both come back as "needs OCR", which is the wrong thing to tell an operator about a broken file. It refuses a partly-scanned document outright where `pdf-inspector` extracts the pages that have text and names the ones that do not, and it reports no page count on success. Three promises `parse_pdf` already makes. So PDF keeps its extractor and the choice is per format. `pdf-inspector` moved twenty releases in the same change, and the one thing that moved with it is where a page-less document is refused — in `classify` now, with the library's own words, which `_pdf_failure` maps to the sentence the suite already expected. `ocr="reject"` is passed although it is the default, and a case asks the mock for it: the extractor's other mode sends the document to a hosted service, the `airgapped` profile rests on this process reaching nothing, and a default is a thing a release of somebody else's library can move. Legacy `.doc`, `.xls` and `.ppt` are deliberately not in the table — the extractor does not sniff their signature and nothing here has watched one convert — and CSV stays text, because it is. The SDK gained the multipart upload it never had: `documents.add` takes `bytes` and `contentType` as a third source and builds the form on **every attempt**, because a `FormData` is consumed by the request that sends it and a retry replaying a consumed one would send an empty body under a `202`. **A generated password comes from one list, and the number is computed.** There were two implementations of the same six-words-and-a-number generator, each with its own word list — 60 words beside `init` and 28 beside the user endpoints — so the product minted credentials at two strengths depending on which door they came through. The weaker one, 35.3 bits, is the door an administrator onboards a colleague through and resets a lost password with. The stronger one described itself as "roughly 70 bits" and was 41.9. One list now, the union of the two at 71 words, and `PASSWORD_ENTROPY_BITS` is derived from it rather than written down — so a comment cannot go on claiming a number the list stopped supporting. `lint:password` refuses a second word list or a second generator, matched on content rather than on a variable name, because the name is the part somebody writing one would change. **Provisioning an organization is one function, in the core.** `initialize` lived beside the `init` CLI, and the CLI is one caller rather than the definition — a control plane minting tenants through an API is another, and two implementations of "what a new organization is made of" would be two answers about `organizations.vector_collection`, which every read and write on the search path depends on. Moving it found a defect that had been reachable since an organization could be the second one. The installation default is a `NULL`-`org_id` provider created once and reused, so `initialize` ignored the endpoint, model and dimensions it was handed whenever one existed — and `main` then built the collection's named vector out of **its own configuration**. A second organization created after somebody changed `NACRE_DEFAULT_EMBEDDING_MODEL` therefore got a collection with a slot no layer would ever write to: the worker derives the name from `layers.provider_id`, so every document failed forever while the API answered `queued`. That is the "layers naming a vector that did not exist" defect, arriving from the other side. `provisionOrganization` returns the provider it actually resolved and names the slot after that. Reproduced first against a real PostgreSQL and a real Qdrant — two organizations either side of a model change, reporting `MISMATCH` — then re-run against the fix. It also picks the *newest* NULL-org provider, which is what `admin-global`'s read already documented and what a bare `LIMIT 1` did not do: there can be several, since changing the default removes only the unreferenced old ones. **Embeddings can come from a hosted API, and nothing about that is a default.** A self-hoster on a laptop has no good embedder — bge-m3 under emulation blows the worker's 120 s budget — and the alternative is somebody else's GPU. The vendor differences live in a sidecar rather than in the worker: a `protocol` column and a three-way branch would put a vendor credential's name in Postgres and therefore in every dump, grow the least observable loop in the system by three response shapes, and make the next vendor a migration. Routing needs no schema at all. The request already carries `model`, and `embedding_providers.model` is a string an operator already fills in, so that is the routing key — two organizations on two vendors with nothing new. Point a provider's `endpoint` at the adapter. The rules it is held to are the whole of the feature. No default vendor, no default endpoint, no route that nobody wrote; an unrouted model is refused by name and never falls through to whichever vendor happens to be configured; with no routes at all the container refuses to start. It is **absent from the `airgapped` profile** rather than disabled in it, because a service that is not there cannot connect to anything and a runtime check on a URL is a check that has to be right — `lint:compose` asserts the absence, against the expected service list as well as the rendered one, so moving it in fails even with the list updated to match. And `docs/config.md` states the trade in those words: **the text of your documents leaves your installation.** Standard library only, which is the argument that keeps the parser at one dependency: this process sees every document's text and there is one operation here. LiteLLM in front of the existing endpoint stays the documented escape hatch, since anything OpenAI-shaped already needs no code from us. **The same sidecar reranks now, and the core still needs no code for it.** `HttpReranker` speaks Text Embeddings Inference's `/rerank`, so a deployment with no GPU had nowhere to point `NACRE_RERANKER_ENDPOINT`; the adapter answers that shape rather than one of its own, which is the whole point — a second protocol in the API would be a second thing to keep in step with the first. Cloudflare, Cohere, Jina and Voyage, and the refusal for an unknown vendor says that OpenAI, Anthropic and Google publish no reranking API rather than only listing the four, because a bare list reads as "yours was forgotten". One reranker per adapter rather than a routing table, and that asymmetry with embeddings is forced rather than chosen: TEI's request carries a query and its texts and **no model name**, so there is no routing key in it to dispatch on. A batch too large is refused rather than split, which is the opposite of the embeddings path and for a reason — a vendor that normalizes scores across the documents it was given in one call would return two sets that cannot be compared, and a silently wrong ordering is the failure this file exists against. A route may also name the vendor's own spelling — `bge-m3=cloudflare:@cf/baai/bge-m3` — and that is not sugar. A layer's named vector is derived from the model, so moving an installation onto a vendor's copy of identical weights would otherwise mean renaming the model, which is a different slot and therefore a collection replaced and every point copied to move vectors that did not need to move. Adding all of it found that the vendor tables were copied into two documents with nothing holding them: `lint:config` compares the `NACRE_EMBED_*` and `NACRE_RERANK_*` literals against `docs/config.md` and does not read the adapter's README at all. The sidecar's own suite holds both tables against `VENDORS` and `RERANKERS` now — from Python, because a check in another language would have to parse the file it is checking. Writing it found that `lint:config` could see half the code it applied to. The check knew about TypeScript readers and the sidecars are Python, so `NACRE_PARSER_ALLOW_PRIVATE_URLS` — which decides whether an authenticated tenant can point that service at the cloud metadata endpoint — had been read by a shipped container and documented nowhere since the day it was added. It reads the sidecars now, and the `parser` CI job became `sidecars`, discovering them rather than naming one: a hard-coded name becomes a component with no tests running at exactly the moment a second one exists. **Object storage is wired.** `NACRE_S3_*` spent its whole life in `docs/config.md` and in the `full` Compose profile while `loadConfig` did not mention it, so a wrong endpoint or a missing credential was silent and MinIO sat there talking to nobody. Configured, ingest writes the bytes to the bucket *before* it writes the row — the reverse strands a document the worker fails on forever while the API answered `queued` — `source_type` becomes `s3`, the worker fetches from the bucket, and the collector removes the object when it purges the vectors. Unconfigured is still supported and is the default: bytes stay in `documents.source_ref`. The client is SigV4 by hand rather than `@aws-sdk/client-s3`: four operations against a container whose job is reading other people's documents, and every one of them verified against a real MinIO, because a signing bug is a `403` that names none of its six inputs. The configuration is validated as a group — all four of endpoint, bucket, access key and secret key, or none — since an endpoint with no credential parses and fails later. Writing that check is what turned up `NACRE_S3_ENDPOINT=minio:9000` being accepted by `new URL`, as the scheme `minio:` with an empty host. **It is five operations now, and the fifth is what found the other two.** That paragraph said four and the header above it said "no listing, because nothing needs to enumerate a bucket" — true of every caller the core has, and the commercial `backup` module is not one of them: an archive whose `verify` refuses a part the manifest does not name has to be able to ask what is there. So `list` went in, with continuation-token pagination, a bound on how many pages it will follow, and a refusal for a response that says truncated and carries no token — an unbounded loop against somebody else's endpoint is a process that never returns. Two defects came out of driving it against a real MinIO, and neither is about listing. The client put **`content-length` among the headers it signs**, and `Content-Length` is a *forbidden request header* in the Fetch standard: undici 7, which is Node 24's, throws `invalid content-length header` where undici 6 simply dropped it. So every `put` this client makes would have failed on a current runtime, on a line that had been correct for the runtime it was written on. The repair is an absence, so the test pins the absence — from the headers and from `SignedHeaders`, because a signature over a header the runtime then sets is a `403` naming none of its inputs. And the XML decoder knew `'` and not `'`, which is what **MinIO actually sends** for an apostrophe in a key. A key it appears in came back mis-decoded, so a `get` would `404` and `backup`'s `verify` would condemn a good archive. Numeric references — decimal and hex — are decoded now, and `&` last, or a key containing `&#39;` decodes twice. **And the reason it had no retries stopped being true the same way.** The header said the callers already retry whole units of work — the ingest queue and the collector — which was true of them and is not true of `verify` and `restore`: those read an archive part by part, so a 1.6 GB artifact is two hundred requests, and one transient `503` from a real cloud store ended the whole run. The operation somebody runs when the database is already gone. Same shape as `list`, one paragraph up: a stated argument that a later caller falsified, in the same file, twice. Three more attempts inside a thirty-second budget, and the judgement is the whole of it. A transport failure, a `5xx` and a `429`; nothing else. A `403` re-signs with the same inputs and arrives at the same refusal — the worst version being `RequestTimeTooSkewed`, where four identical failures hide a one-line diagnosis — a `404` is an answer, and `501` is the one status in the 5xx range that is permanent, so it is excluded by name rather than by falling under `>= 500`. **Full jitter over the whole window**, because the callers are a fleet: worker replicas share a bucket, and a blip they all see is one they would all retry from at the same instant. `Retry-After` overrides the formula and is capped, or a mistaken value parks a restore for an hour. And there is a **budget** as well as a count, checked before sleeping — attempts alone bound one request, and two hundred parts × four attempts is a run that spends twenty minutes discovering the store is down. Every operation here is idempotent and that is a property rather than a hope: a key is derived from identity, never from a sequence, and the body is a `Uint8Array` this process still holds, so an attempt is replayable byte for byte. A streaming client could not make that claim, which is why a streaming rewrite would have to revisit this rather than inherit it. The loop is **one function three call sites go through**, and that is the design rather than tidiness: `#send`, `list` and `ready` each called `fetch` with nothing that knew there were three, and a retry added to `#send` alone would have left a restore's *listing* unretried — the request that decides whether an archive's parts are all there, so the check that refuses a stray part becomes the check that fails on a blip. `ready` is the one caller that opts out, at one attempt: a readiness probe's job is to answer now, and retrying inside it turns "the bucket is not answering" into no answer, which an orchestrator reads as a pod to kill. **And the file's own header claimed a check that did not exist.** It says every property here is verified against a real MinIO before it is believed — true of somebody running a script by hand, and false of CI, which had no object store at all. The `lint:tokens` shape exactly: the property held, and that is what made the absence invisible. `s3-live.test.ts` is six cases against a real store, and four workflows run the unit suite so all four now start one. `lint:workflows` is what knows there are four. It compares the `NACRE_*` every job running that suite sets, by name and computed rather than listed, and refuses outright if it finds no such job. Taking a variable out of `flake-hunt.yml` names it — which is the case that matters, because a nightly hunt going red at 3am for a fixture nobody changed is how a hunt stops being read. **Per job and not per file**, which the first version got wrong and which porting it to `nacre-enterprise` is what exposed: two jobs in one workflow can run different projects and legitimately need different services — that repository's `acl-invariants` runs `test:acl` and has no object-storage case at all — so a file-level union asks the wrong job for a fixture it has no use for. Both repositories carry the same text now, which is what "a check is the safe thing to copy" is worth only if the copies are actually the same. **The live case written to prove the re-signing could not fail, and that was measured rather than assumed.** Its first version said a client replaying the first attempt's headers would be caught here. Restoring exactly that left the file green: the backoff is stubbed to return immediately, so both attempts land in the same second, and S3 refuses a stale signature only past fifteen minutes. No suite that finishes in under fifteen minutes can show it. The re-signing is pinned in the unit file, where the clock is a seam; the live file claims what only a store can answer — that a retried write really lands — and says so. **And the paragraph above about the SDK was wrong about its size.** It said "tens of megabytes and hundreds of transitive packages"; `@aws-sdk/client-s3` at 3.1113.0 installs **26 packages and 22 MB** with `--omit=dev`, measured rather than remembered — v3 consolidated its clients since that sentence was written. The argument survives the correction and is smaller than it was. What the SDK would genuinely buy is credential providers — IRSA, an instance role, SSO — and if that is ever needed the shape is `@aws-sdk/credential-providers` feeding a session token into this signer, not the whole client: the signing is the part that is written and verified, and the credentials are the part that is not. **`docker compose --profile minimal up` has now been run from a clean clone**, and the whole loop driven through it: init, layer, ingest, indexed, search, grant, revoke. It found four more things, one of them a regression from the sweep lease two commits earlier — `sweep_claimed_at` was set on claim and never released, so after one pass a document was locked out of the loop for the full `NACRE_INDEX_LEASE`. Fifteen minutes, with the worker log silent after one success. Every test passed throughout. **Every profile has now been started, and two of them are in CI.** `demo` was driven on a real bge-small to the three-answers demonstration — an administrator reaching the contract number, an engineer the same question without `contracts`, a contractor the handbook alone; `full` took a real PDF through MinIO to `indexed` and back out of a search, and refused the same bytes declared `text/plain`; `hosted` gave all three of the adapter's refusals — no routes, no credential for a routed vendor, an unknown vendor; and `airgapped`'s property was shown by running its embedder with `--network none` off a seeded volume. `lint:compose` pins what each profile *contains*, which is a different question from whether it starts — so `demo` has a job of its own now, beside `e2e`. It is the profile `docs/quickstart.md` tells a reader to run and the one that produced four defects the last time somebody ran it by hand, and it tests what `minimal` structurally cannot: that stub embedder returns a constant vector, so relevance decides nothing there. The job asserts the demonstration itself, and the two refusals are the half no unit test can prove, because a unit test builds the permission plan it then asserts against. Checked by granting the contractor `read` on `contracts` and watching it go red, then revoking it. `full`, `hosted` and `airgapped` are still started by nobody on a pull request. Each wants something CI would have to be given — a bucket and a reranker, a vendor credential, a seeded model volume — so the honest statement is that they are covered by `lint:compose` and by hand, and not that they are covered. **`hosted` turned out not to need one, and saying so is the finding.** Its three refusals — no routes, a routed vendor with no credential, an unknown vendor — were already asked of `load_routes`, in the sidecar's own suite, in the `sidecars` job. What a `ConfigError` case cannot see is the half a deployment actually meets: `serve` catches that error, prints one JSON line and raises `SystemExit(1)`, and if the `except` were narrowed or the status dropped the process would come up, bind its port and answer every request with an error from a service the orchestrator believes is healthy — worse than not starting, because nothing restarts it and nothing says why. That is the whole of what starting the profile in CI would add over the cases already there, so it is asked directly: the module run as a **subprocess**, with a broken environment and with `PATH` and `HOME` only so nothing an operator's shell exports can configure it into starting. `assertRaises(SystemExit)` would have proved the exception and not the status a shell sees, and the message has to arrive on a stream `docker logs` shows. Both halves were restored — the exit code and the stream — and each failed all three. **The ACL tag cache is gone** (migration 0016). `docs/authz.md` specified `acl_tags` in the vector payload as a second filter beside the layer bound, and the whole subsystem was built — the retag sweep, its lease, `acl_version`, the 8-byte hashes, `nacre_acl_propagation_lag_seconds` and its alert — while `buildFilter`, the only filter builder, never emitted the clause. It kept a payload field fresh that no query read, and paged an operator about it. Removed rather than finished, on two grounds. It saved nothing: the resolver computes the whole permitted set from `grants` per request, so the join it was avoiding does not happen. And it could not express what the product now sells — the tags were per *layer*, from allow-only grants, so applied as the specified `must` a caller reaching a document through a document-scoped grant would have been filtered out. Making them per-document would fix that and leave the remaining cost: intersecting a live plan with a cached tag set delays *grants* while doing nothing for *revocations*. Invariant I4 is unchanged and stronger. It is structural now rather than temporal — nothing sits between a grant change and the next request — and the T11 cases assert it against a real database on every run, which is a better guarantee than a gauge reading zero. The docs are still normative rather than descriptive, and in places still ahead of the code. Where one disagrees with the tree, that is a bug in one of them — say which, and nothing is currently outstanding. The two that were: **`workspace_admin`** was in `users.role`'s CHECK and in nothing else — migration 0017 removed it, because "administers a workspace" is a grant on a workspace scope and not an organization-wide role, and the model already said so better. And **an `org_admin` could issue a grant naming any uuid**, because `referenceAllows` returns true for the role before the scope is placed; `PostgresGrants.issue` checks existence now. Neither was a leak — the pre-filter's unconditional `must: org_id` held in both cases — and both made `404` stop meaning what invariant 4 says it means. The third was inside one file and disagreed with itself. `/oauth/consent`'s description said a signed-in person chooses which **service account** an application acts as, and that the token "acts as that account and **never** as the person who approved it. That is the design rather than a detail." Its own request schema, six hundred lines earlier in the same document, says *"Omit it to approve as yourself … naming nobody is a delegation, where the token acts as the signed-in person"* — and the consent screen's default is exactly that case. So the contract described the flow as the one thing it is not, in the document that is normative here, while the schema beside it was right and so were `docs/authz.md`, `docs/mcp.md` and `docs/upgrading.md`. Prose written before a feature, in a file the feature only touched further down. This is the class the file already names as resisting a check, arriving from a new direction: not a claim repeated across documents but a claim contradicted by a schema inside the same one. No mechanical rule was added, because the ones that would have caught it — an operation's prose against its own schema's requiredness — are narrow enough to be defeated by rewording, which is the shape of check this repository keeps deleting. `grep` found the other three copies, and they were correct. **Every case of T1–T44 in docs/authz.md runs** — all forty-four, since the security review added T42–T44 — against real services, plus the truth table, a property-based comparison against the reference implementation, and a round trip that puts the worker and the search path against each other. `acl-invariants` is a gate on what that document specifies — and only on that. A leak nobody thought to write down is still unguarded, and adding a case to `test-plan.ts` is how that changes. The effective-principals cache is wired in. `NACRE_ACL_CACHE_TTL` had been validated at startup and read by nothing, and the cache itself was written *and tested* — seven cases in `propagation.test.ts` exercising a function no request path called, so every request recomputed the transitive group closure and the suite proved a code path that did not run. A cache tested and never called is the same shape as a variable accepted and never read. Caching a permission input is safe here for a structural reason rather than a temporal one: the key carries `organizations.groups_version`, which triggers bump on every change to `groups`, `group_members` and `grants`. A revoked grant is not served stale — the next request composes a different key and the old entry is never asked for again. Verified against a running database (create a group, add a member, remove one, grant, revoke, delete the group: the version moved for every one) and pinned by two tests that ask the *adapter* rather than the module. The TTL bounds memory and nothing else, which is why the refusal that compared it against a propagation SLA went away with the SLA. `NACRE_LOG_LEVEL` and `NACRE_LOG_FORMAT` are honoured. Both were validated at startup and read by nothing, so every process wrote JSON at one level whatever the deployment asked for — and they are among the first variables anyone sets. Configuration errors and the output of `init` and `migrate` deliberately stay outside it: the first happens before there is a level to consult, and the second is program output a person ran the command to read. The one that would have been a bug: the MCP STDIO transport now points the logger at stderr, because the default writer sends `info` to stdout and stdout there carries JSON-RPC frames. A log line in the middle of the stream is a frame the client cannot parse — checked by running it and confirming stdout stayed at zero bytes. `installGuards` also stopped reporting a clean shutdown as an error, which is what turned every deploy into a line a dashboard counts. A search leaves a `query_hash` in the journal, and the query itself where `NACRE_AUDIT_QUERY_TEXT` says so. `docs/audit.md` promised both — "a query hash is stored instead", and the flag for deployments that decide otherwise — and the event carried a count. So the hash the document calls "enough to investigate an incident" did not exist, and the flag was a third variable read by nothing. The hash is unconditional and always of the whole query even when the stored text is truncated, or two records of one long query would not match each other, which is the comparison an investigation makes. `latency_ms` went in beside it: the number was already measured for the histogram and never reached the journal. `GET /v1/documents/{id}` carries a presigned `source_url` where a deployment stores bytes in object storage, and `NACRE_PRESIGN_TTL` is finally read. Minted after the permission check and only for a caller holding `read`, so rule 6 keeps it away from `write` alone — and absent entirely for an inline document, for one ingested by URL, and for a deployment with no bucket. The search response does **not** carry one, which is a deliberate deviation from what `docs/mcp.md` and the OpenAPI document said, raised rather than absorbed. A presigned URL is a bearer capability that outlives the check which minted it, so answering a question about relevance by issuing one per hit hands out ten capabilities where the caller wanted an ordering, most never followed. Both documents now say so. Every property of the signing was checked against a real MinIO, including the ones that must fail: no signature, one character of the signature flipped, the link repointed at another key, the expiry rewritten in the URL, and the window elapsed — 403 for all five, 200 for the untouched link. The first attempt at that check reported a pass on a tampered signature; the tamper had not applied, because the regex assumed a one-character signature and it is sixty-four. **Tokens can be signed with an Ed25519 key.** `NACRE_JWT_PRIVATE_KEY_REF` was in `docs/config.md` from the beginning, read by nothing, with `loadJwtKeys`'s own comment saying "until that lands". What it buys is that **the key which verifies is not the key which signs**: with a shared secret every process that can check a token can also mint one, and reading a container's environment gets an attacker from "can check tokens" to "can act as any administrator in any organization". The public half is published at `/.well-known/jwks.json`, which `404`s on a secret-based deployment — a shared secret has no publishable half, and an endpoint that produced one would be publishing the signing key. And a process that only verifies is now configured with the public key alone. `loadJwtKeys` separated `signing` from `verification` in its return, but its only input was `NACRE_JWT_PRIVATE_KEY_REF` — so the MCP transport, a resource server that never signs, still had to be handed the private key to derive the public one, and the blast-radius argument was true of the type and not of the deployment. `loadJwtVerification` closes that: `NACRE_JWT_PUBLIC_KEY_REF` (with `NACRE_JWT_PREVIOUS_PUBLIC_KEY_REF` for the rotation overlap) gives a verifier the public half and nothing else. It refuses a file that contains private material outright, because the whole point is that the signing key is nowhere near the process — and a token signed with the private key was checked, by running it, to verify against a verifier loaded from only the public one, while a forgery from another key was rejected. `file://` only, and Ed25519 only. Every platform with a secret store presents one as a file, and a `vault://` scheme would put a network client on the startup path; RSA needs a size and a padding choice and EC a curve mapping, while Ed25519 has no parameters to be wrong about. Both refusals are at startup, by name. Setting a secret and a key ref together is refused too: two answers to "what signs a token", and resolving it by precedence leaves the other one configured, apparently in use, and ignored. The verification algorithm is pinned rather than taken from the token header. That changes nothing observable today and the check confirmed it — `jose` already refuses an HS256 token offered against an asymmetric `KeyObject`, and the forgery was tried three ways against a running server with the pin removed and refused every time. It is there because "the library happens to stop it" is not the same statement as "this deployment accepts one algorithm", and only the second survives a dependency upgrade. `init` no longer prints a password that does not work. The upsert has always kept an existing hash — a re-run must not let whoever can run the command lock out the person who did — but the output did not know that, so a second run generated a plaintext, printed it under "a password for signing in, which is not printed again", and the database kept the old one. Found by running it twice and trying both. It reports whether *this run* set it, and says so instead when it did not. `docs/quickstart.md` had the matching hole: it said "proper token issuance is not built yet" next to a token that expires in an hour, months after email and password sign-in landed, so the documented path ran out at hour two. **A fix applied in one place and not in its sibling is the most repeated defect here, and the response is a check rather than a second fix.** One day produced six instances of it: `initialize` negotiated on Streamable HTTP and announced on STDIO; `server/discover` was added to one transport and not the other; `$http_host` was needed in four nginx locations; `.env.example` seeded a variable `docker-compose.yml` explained at length it deliberately omits, and `env_file` on the shared anchor delivered it anyway; `workflow_dispatch` was on three workflows out of four, and on none in a sibling repository; and `serverVersion` was carried by two transports and passed by neither entry point. The shape is always the same: **a property that has to hold in N places, with nothing that knows N.** So the rule is that finding one instance is not a licence to repair it — the repair is a check that asks all N. That is what `lint:config`, `lint:publish`, `lint:legal`, `lint:compose`, `check-upgrading` and the SDK's `coverage.test.ts` already are, and what `transport-parity.test.ts` and `lint:workflows` were added as: the two transports are driven from **one table**, so a case is asked of both and a method implemented on one is a failure; and every workflow that gates a pull request is required to be startable by hand, because that rule had been living in a comment and a comment does not travel to the next file, let alone the next repository. The two that resist mechanising are worth naming rather than pretending otherwise. A **claim repeated across documents** — `authorization_servers` was described as "absent and deliberately never pointed at Nacre" in four files and became wrong in all four at once — has no check; the answer is that correcting one is not done until `grep` has found the others. And a **rule stated only in a comment** is the one that produced `workflow_dispatch`: if a comment explains why something must be true, that is the signal it wants to be a check. **Test what you write by running it, not only by testing it.** Twelve of the worst defects found so far were each invisible to a green suite and obvious within a minute of starting the processes: the worker indexing nothing at all, layers naming a vector that did not exist, MCP answering in a shape no client can parse, a propagation gauge that could never fire, a retag loop that starved garbage collection entirely, a document stranded in `parsing` with no error anywhere, `migrate()` throwing ENOENT from the built package, search returning no text at all — because every test asserted on the payload, which *was* the response — a duplicate service account name answering 500, the worker erasing a document's title, which was visible only in a screenshot, a service account key sitting in the idempotency cache in plaintext for 24 hours, found with `redis-cli GET`, re-indexing leaving every previous point in the index, found by noticing that a search for five results returned four, and two whole subsystems that only ever worked because development connects to Postgres as a superuser — service account keys answered 500 and the worker indexed nothing the moment an operator followed the rule in `docs/config.md` about not doing that. **A document over 22 KB never indexed, and the layer went on answering.** Both embedding clients sent a document's whole chunk list as one `input` array with no bound of their own. An endpoint does not split a batch that is too large — it **refuses** it, and Text Embeddings Inference, which every profile here starts, answers `413` above `--max-client-batch-size`, default 32. A chunk is 800 characters, so anything past roughly 22 KB produced more than 32 of them and failed permanently: nothing retries `failed`. Nothing looked wrong. The layer kept answering searches out of the documents that *had* indexed, so retrieval was quietly worse with no error anywhere a person looks — found on a running stand at twenty-six failures out of fifty, where the successes in the same log are all `chunks: 2` and `chunks: 3`, which reads as bad luck rather than as a threshold. It is the two-clients-with-nothing- making-them-agree shape again, and the repair is `embedInBatches` plus `lint:embed-batch` asking every file that posts to that route. Three more came out of the branch, and the order they arrived in is the lesson. The helper **skipped its own count check on the single-batch path** — almost every call, since a document under the limit is one batch and a query is one text — so a short answer misaligned a document instead of raising; a test that predates the batching caught it, which is the argument for one path rather than a fast one beside it. Then the smoke test turned out **never to have sent its document**: `python3 - … < big.txt` with the program on a heredoc makes the heredoc stdin, so the content was the empty string, the document reached `indexed` with zero chunks, and the assertion failed *accusing the product*. And the same section then broke the PDF one by leaving eighty-six chunks of filler in the layer, which is a test failing somewhere other than where the fault is. A harness is code, and it fails in the same shapes. **Cursor pagination did not advance.** `timestamptz` holds microseconds and a JavaScript `Date` holds milliseconds, so a cursor built from `created_at.toISOString()` is truncated — and a truncated bound is *strictly less* than the row it came from, so `(created_at, id) > (bound, id)` matches that row again. `GET /v1/layers?limit=1` returned the first layer eight times in a row against a real database, and a client following `next_cursor` looped forever. `GET /v1/audit` had the mirror image: it orders descending, so the same truncation **skipped** every event between the bound and the real value — a record silently missing from the one surface that promises a precise answer. Every listing carries `created_at::text` now. Found in the same test: **`GET /v1/workspaces` did not paginate at all.** The statement had no seek clause and no `LIMIT` — it fetched every workspace, filtered in application code, and handed the whole list to `pageOf`, which then reported another page because the list was at least as long as the limit. Every page was the complete list and there was always a next one. Neither was visible to a green suite, and for a reason worth naming: a test that asks for one page checks that a page comes back. Only walking the collection to its end catches a cursor that does not move, and nothing walked one. **Migrations could not run as anything but a superuser**, which is the third subsystem found this way and was discovered by writing the Helm chart's provisioning rather than by any test. Every tenant table is `FORCE`d, and `FORCE` is exactly what makes a policy apply to the table's *owner* — so the four migrations that read a tenant table (`0006` duplicate layer slugs, `0007` a lease backfill, `0017` a role rewrite, `0018` a group-member dedupe) each evaluated `current_setting('app.current_org')`, unset during a migration, and failed. `0001`–`0005` applied first, so the database was left half-built with a message naming a GUC and nothing about roles. `docs/config.md` had it backwards in a table — "the owner … applies (tables are `FORCE`d)" — which is the configuration that cannot work. The owning role needs `BYPASSRLS`; the application role must not have it, and still does not. The migrator refuses up front now, with the whole provisioning block, and only when there is something to apply — so a re-run on an up-to-date database stays a no-op whatever role it connects as. The block includes `WITH ADMIN OPTION`, which `0008` needs to grant `nacre_worker` onward to `nacre_app` and which plain membership does not confer. `0008`'s own hint says to grant membership, and following it exactly still fails; that migration is applied everywhere and its text is checksummed, so the correct remedy could only go in the runner. Verified by provisioning each shape against a real PostgreSQL and running the real migrator: a plain owner refused before touching the schema, a superuser applying every migration, a `BYPASSRLS` owner applying every migration, and `nacre_app` afterwards still unable to create a table, still subject to every policy, and still holding only `INSERT, SELECT` on `audit_events`. Counts stood here and in two other files, and each went stale on the next migration. Two more, found by reading rather than by running, and both invisible to a green suite for the same reason: they are about *scale*, and the suite runs one of everything. Both background sweeps selected the same rows on every replica, so scaling the worker out — the documented response to a climbing propagation alert — did nothing at all. And a `/metrics` scrape cost three queries per tenant with no cache and no authentication, so a scrape loop was a denial of service that looked like monitoring. **A second factor is in, and it is TOTP.** Everything that authenticated a person here was one secret: a password, or an ID token from an issuer the operator trusts. A password that leaks is an account, and on this product an account is a set of documents somebody decided who may read. The arithmetic is `packages/core/totp.ts` and is held against **RFC 6238's own vectors** rather than against itself — a code generator tested on its own output agrees with itself and with no authenticator anybody owns. SHA-1 deliberately: the weakness it is retired for is collision resistance, which HMAC does not rest on, and choosing SHA-256 would buy nothing measurable while meeting a person whose app shows six digits that never work. Everything else is what a database decides, so it was driven against a real PostgreSQL: the replay bound, a recovery code spent by the UPDATE that finds it, and the lock after five wrong codes. That run found the failure counter reading one parameter twice — `SET failed_attempts = $2 … WHEN $2 >= $3` — which Postgres refuses with `text versus integer` at run time and at no other time. It type-checked, and it would have passed against a mock. The same mistake appeared again in the test's own fixture an hour later, which is what made it worth a comment rather than a fix. **One variable seals it, and the file form was deleted before it shipped.** `NACRE_2FA_KEY_REF` existed for a day beside `NACRE_2FA_KEY`, with a paragraph arguing that a file is the better place for key material and a refusal for setting both. The argument is true and is not worth a second variable: an operator who wants the file writes `NACRE_2FA_KEY=$(cat /run/secrets/…)`, one line, where the value is set — while the product would have carried two spellings in `loadSecondFactorKey`, in `docs/config.md`, in `.env.example`, in `docs/upgrading.md`, in the OpenAPI document and in the sentence the console shows an operator who has configured neither, for as long as the product exists. It was removed at the only moment removing it is free: 0.18.0 had not been published, so nothing was ever configured with it and there is no compatibility to keep. A compatibility shim is cheap to add on the day and permanent afterwards, which is the asymmetry worth acting on before a release rather than after one. `NACRE_JWT_PRIVATE_KEY_REF` beside it is file-only and is not the same shape: there is one form there too, and it is the file because a private signing key must not be in the environment at all. The rule both follow is one form per thing, not one form for everything. `lint:config` is what keeps the deletion honest — it holds every variable in both directions, so a resurrected reader with no documentation and a documented variable nothing reads each fail — and a case asks `loadSecondFactorKey` directly that the old name is not read, because "unset" and "set under a name nobody reads" are the same answer and only one of them is what an operator meant. **Unconfigured is a supported state and the feature is simply absent.** With no `NACRE_2FA_KEY` there is no key to seal a secret with, so enrolment is refused, the console says why, and nothing stores a secret in the clear in the meantime. A product that half-does a second factor is worse than one that does none, because the operator believes something. Three properties are worth naming because each is a way to get this wrong. A **code is single-use** — the step it belonged to is stored, so a code read over a shoulder is not worth a second use, and the visible cost is that enrolling and immediately signing in means waiting for the next one. The **brute-force bound is in Postgres**, against the grain of the rate limiter beside it: that one fails open because it is not an authorization control, and this one is. And the whole surface is **under `/v1/me`** — an administrator resets a password and deliberately cannot touch a second factor, or it would stop being a thing the person holds. `login()` returns a union now rather than a nullable pair, which turned every call site into a compile error until it said which outcome it meant. That is how the CLI and the console got their second field: neither would have been noticed by a type that let the challenge be ignored, and ignoring it means issuing a session to somebody who never produced a code. **A password can be recovered, and the whole feature is absent where no relay is configured.** `POST /v1/users/{id}/password` has existed since there were users and it is an *administrator* setting somebody else's; the person who forgot theirs had no route, and on a single-administrator installation — which the open core mostly is — neither did the administrator. The route people find instead is `psql`, which is the shape this repository keeps closing. **The token carries its organization**, `.`, and that is the design rather than a convenience. Resolving a credential outside `withOrg` has exactly two mechanisms here, and migration 0008 says in its own words that `users` gets neither "as a decision rather than an omission". A token that names its tenant means redemption reads through `withOrg` like everything else, so the one path a stranger reaches unauthenticated opens no cross-tenant lookup. The organization id is not a secret from the person holding the link — it is in their own `/v1/me` — and the half beside it is. `204` whatever happened: no account, an address in two organizations, a disabled account, a rate limit already met, a relay that refused. Anything else makes the one endpoint needing no credential into the account-enumeration oracle that sign-in is careful not to be. **A reset does not touch a second factor**, or an email account would be a way around one. It does end every other session, because a reset is what somebody does when they think their password is known. `nodemailer` is the second dependency this package has taken, and the argument is the one the parser made in reverse: it has **no dependencies of its own**, and the alternative is two hundred lines of SMTP here — dot-stuffing, quoted-printable, and header encoding over addresses that come out of the database. Header injection on that last one is a defect with a name, and trading a zero-dependency package for the chance to write it myself is not a trade worth making. `GET /v1/auth/methods` exists so the console can leave the link **off the screen** rather than showing one that answers `404`. A screen offering what the server refuses is a defect this console has already shipped once, and the fix was the same shape: ask, do not assume. Wiring it turned that up again from the other side. `recovery` was spread into the `Login` constructor instead of the server options and **nothing complained** — an excess property in a spread is not checked — so the routes would have been mounted nowhere while every gate stayed green. Found by reading the file rather than by any check, which is the honest version of how it was found. Redemption writes `users.password_hash` and is therefore the second written exemption in `check-platform-admin-target.mjs`. It is a real exemption rather than a routing change: the only way to reach that statement is to hold a single-use secret emailed to the address on the row it writes, so the actor and the target are one person and there is nobody to escalate over — and refusing a platform administrator there would leave the account that administers the installation as the one account whose only recovery is `psql`, which is the hole this endpoint closes. **A person can change their own password.** Everything that set one belonged to somebody else: `POST /v1/users/{id}/password` is an administrator issuing a generated one, and recovery is for a password that has been *forgotten*. The ordinary case — you know your password and want a different one, because somebody else knows it too — had no route at all, and on an installation with no relay configured recovery is not there either, so the answer was `psql`. It takes the current password and nothing else. A session is not enough, on the same argument that makes removing a second factor take a current code: changing the password is the first thing somebody holding a stolen session does. And a second factor is deliberately **not** asked for, because it bounds sign-in — demanding a code would mean somebody whose phone is lost cannot change a password they know is compromised. A wrong current password is **`403`**, and that choice is about clients rather than about semantics. `401` is the obvious status and is the wrong one: on an authenticated route it means "your session is over", and every client here renews on it and replays — so a typo would spend a refresh token, fail again, and arrive as two failures with a renewal between them. Not `404` either; the caller is looking straight at their own account. Every other session ends and this one is replaced, in that order inside one transaction — the pair is inserted **after** the revocation, or the endpoint answers `200` with a refresh token it has just revoked and the person is signed out fifteen minutes after a change that worked. That ordering is what the live case measures, by counting live tokens rather than asserting on each: swapping the two statements leaves zero. The console's Security screen grew the form, and writing it found that the whole view **returned early** when no `NACRE_2FA_KEY` was configured — so on an installation with no second factor, a message about TOTP would have hidden a password control that works. Two panels rendered independently now, which is the same "a screen must not refuse what the server allows" rule the message beside it exists for, arriving from the other side. Two more things came off running the console rather than testing it, which is the rule this file keeps re-learning. `scripts/screenshots.mjs` had **no `/v1/auth/methods` fixture**, so the call that decides whether a recovery link exists got the 500 an unstubbed call gets and `sign-in.png` was a picture of a screen with no link on it — the missing-`/v1/me` defect from the same script, one release later and on the one screen rendered signed out. And every committed image predated the Security view, so all twelve show a nav that is missing an item; regenerating them is part of this rather than noise. The link itself sat at **zero pixels** under the Sign in button — `.hint` carries no top margin and `.btn-block` only a small one — so a mis-tap on a full-width button lands on "forgotten your password". **`screenshots.mjs` measures that now**, which is what turns it from an instance into a repair. `lint:admin-layout` reads the stylesheet and this is geometry; that script already opens every screen in a browser, so the question goes where the browser is: for every control, how far is the nearest box that ends above it and overlaps it horizontally. The general question rather than control-against-control, because the sibling repository learned that the narrow way round and its next instance was a control under a *paragraph*. **Its first version reported thirty things across fourteen screens and named the one real defect among them**, which is a check nobody reads. Three of the four arrangements it flagged are ones where being close is the design: a field's own label four pixels above its input, one table row's action against the next row's, and an inline sibling on the same line whose box ends a pixel high because the two are baseline-aligned. Each is excluded by name and with its reason. The fourth was the check's own defect — a modal is a layer *over* the page, so the New user dialog's Cancel button was measured against the users table behind it, which is a fact about stacking. It measures one root at a time now. Zero findings on a clean tree, and restoring the flush link names exactly it. **And the run itself is in CI**, as `console`, which is what stops this being a gate somebody has to remember to run. Playwright is installed for the job rather than becoming a dependency. The **images are not compared** and that is deliberate: whether a layout fits is decided by whatever font the runner has, so a byte comparison would be red on one machine and green on another for reasons about neither the code nor the design. What is asserted is what a browser decides — geometry, page errors, missing fixtures, and which view rendered. That job is only possible because the clock is frozen. `accounts.png` and `people.png` used to change on a wall-clock boundary with no code change — two runs eleven minutes apart differed because a `created_at` crossed 181 days, since the fixtures carry absolute dates and the views render relative times. A regeneration that rewrites unrelated files is how a screenshot diff stops meaning anything. `page.clock.setFixedTime` pins it, checked by rendering twice and comparing bytes. **The four rules those runs apply are a module now, and the reason is on the other side of a boundary this repository cannot see.** `nacre-enterprise` ships four commercial screens into a console it does not own: they are drawn by *this* package's `dom.ts`, `pick.ts` and `admin.css`, through the extension seam that exists precisely so there is not a second console over there. Every layout defect those screens have had — six of them — was found by building the image and looking at it, because the rules that catch exactly those lived inside `screenshots.mjs` and were reachable only from here. A second copy of "a control needs eight pixels of headroom" in the other repository is this file's first paragraph at the largest scale it is available in: **a property in two places, across a boundary where no check can read both sides.** So they are `scripts/layout-rules.mjs`, which that repository fetches at the version its image is built `FROM` — its own technique for the console contract, pointed at one more file. Two things make that possible and both are constraints on anyone editing it. The failure sink is an **argument** and not a module-level array: two callers report differently, and a rule that closes over one of them has chosen. And nothing there imports playwright — a rule only ever calls `page.evaluate` — so the file can be fetched and imported without installing anything of ours. `RULES` is the export that matters, and `shot` iterates it rather than naming four functions. A caller that lists the rules is a caller that forgets the fifth, and the fifth would otherwise reach one console and not the other, which is the whole defect one paragraph up. Moving them changed no behaviour and that was measured rather than argued: the same twenty screens, images **byte for byte** identical, and the rules still fail through the new path — restoring `.under-action { margin-top: 0 }` names `sign-in: "Forgotten your password?" sits 1px under "Sign in"`, which is the instance this file already records. It paid immediately. The first run of the other repository's pass named two classes this stylesheet does not define, in screens that had shipped with them. **And three of the four routes that hash answered `500` under load.** `core/passwords.ts` bounds how many scrypt calls run at once — the pool is libuv's and is shared with DNS, so an unbounded one stops the rest of the API on a name lookup — and its own header says "the caller answers 503, which is the honest response to 'this process cannot verify a password right now'". Sign-in caught it beside the call. Creating a user, an administrator resetting a password, and redeeming a recovery link each turned a loaded process into an internal error, which a client reports as a broken server and an operator investigates as a bug. A rule stated in a comment and held in one of four places, which is the shape this file names twice already — and the count moved twice on the way in: the fourth route was password recovery and the fifth is the change above. The repair is one problem builder and one `handledTooBusy`, called from the **two** error boundaries there are. Two rather than one because authentication splits the request path: sign-in and recovery are reached without a credential and run before the section that has one, so a single `catch` cannot cover both, and saying so beside them is what puts the next hashing route in one of the two. The check is the four routes driven end to end. Each half of the defect was restored and each named exactly its two. **The second exemption is what made the mechanism matter.** Exemptions are keyed by file, so a second method in the same file writing the same statement text was waved through by an argument written about the first one — a write nobody argued for, admitted by a check whose whole subject is arguing for writes. Each exemption now has to match **exactly one** section: zero is the stale entry it already refused, and two is the new hole. Both branches were checked by producing them. **A second factor you cannot be phished out of.** TOTP bounds a stolen password and does not bound a convincing page: six digits typed into a site that looks like this one work exactly as well there as here, and the person who typed them has no way to know. WebAuthn is the kind whose signature covers the origin, so an assertion produced for the wrong site does not verify at the right one — that is the whole reason to carry a second kind rather than more of the first. It needs **no configuration at all**, and that asymmetry is the feature rather than a convenience. A TOTP secret is shared, so it has to be kept and therefore sealed, which is what `NACRE_2FA_KEY` is for; a WebAuthn credential leaves a public key here and nothing else, so a database dump hands over nothing that can produce an assertion. The relying party is `NACRE_CANONICAL_URL`'s hostname and the origins are that URL's plus `NACRE_API_ALLOWED_ORIGINS` — a `NACRE_WEBAUTHN_*` variable would be a second answer to a question the deployment has answered twice. So the installation with **no** key now offers the *stronger* of the two and not the weaker one, which is most of them. `packages/core/webauthn.ts` has no dependency and needs none: `node:crypto` imports a **JWK** directly, and COSE and JWK are the same parameters under different names, so the conversion is a field mapping rather than ASN.1. The CBOR decoder is a subset — CTAP2 requires canonical encoding, so every indefinite length, every tag and every non-canonical integer is a refusal rather than a branch, and the bounds are depth, entries and bytes. The fixtures are **genuine bytes from Chrome's virtual authenticator**, driven through the Chrome DevTools Protocol by `scripts/webauthn-fixtures.mjs` and regenerable. Hand-encoding them would have proved the verifier against whatever the encoder believed, which is the fixture-written-to-match-the-code shape this file already names twice. ES256, RS256 and EdDSA, and the refusals beside them. Three properties are the ones a verifier gets wrong. The origin is compared by **equality** and never by suffix, because `https://evil-nacre.work` ends with `nacre.work`. A challenge is **single-use**, spent by the `UPDATE` that finds it — an assertion captured on the wire is replayable for exactly as long as its challenge is, and nothing else in the ceremony stops that. And a challenge issued for an *enrolment* cannot be spent on a sign-in, which is in the `WHERE` clause rather than checked after: enrolment is asked for by somebody already signed in, and one pool would let a session mint the input to a ceremony it is not in. **The first version of the counter test could not fail.** It asserted `sign_count > 0`, which the registration already satisfies, so removing the write left it green. It compares against the counter read out of the assertion's own authenticator data now. Writing the routes found **two defects that shipped in 0.18.0**, and both are the same shape: a path nothing had ever asked the *server* for. `DELETE /v1/me/second-factor/{id}` answered `404` from the day it was written. Its condition was `!rest.includes('/')` over a `rest` that is `/{id}` — false for every id, always — so **a second factor could be enrolled and never removed**, on the one surface an administrator deliberately cannot reach on somebody's behalf. The store's own live case calls `remove` directly and passed throughout, because the store was never the broken half. `GET /v1/auth/methods` answered `404` too, because `handleAuth` refused anything but `POST`: every route there produces a credential and takes a body, and this one is a read. So the endpoint that exists **so the console can leave the recovery link off the screen** never answered, and the console — reading `password_reset` off a problem document — hid the link on every deployment, including the ones with a relay configured. The feature did the exact opposite of its purpose, in the file whose header explains why it is there. `contract-surface.test.ts` is the repair rather than the second fix. It excluded `/auth/*` **by prefix**, on the argument that those are all POSTs whose bodies it does not construct — true when it was written, and it silently stopped covering the one `GET` added later. A blanket exclusion is a check that shrinks without saying so; the condition is `op.method === 'GET'`, which is what the argument actually was. It names exactly this defect when the refusal is restored. And a **recovery code could not be spent where there is no sealing key**: `verify` opened with `if (key === undefined) return false`, which is right about TOTP and wrong about the sheet of codes redeemed by the same method. The guard sits below the redemption now, and the select it guards asks for `kind = 'totp'` — which also stops a mixed account trying to open a `NULL` secret. **The whole ceremony is driven in a real browser against a real database**, by `scripts/webauthn-e2e.mjs`, and that is what says the wiring works rather than each half separately: Chrome's virtual authenticator over CDP on one end and Postgres on the other, with the console's encoding, the routes, the store and the verifier in between and nothing stubbed. Fourteen assertions — the panel offering one kind and not two on an installation with no key, ten recovery codes, the JWK and the algorithm in the row, a sign-in on the key alone, the counter moving, a forged challenge refused, and the factor removed on an assertion. That last one is the 0.18.0 `DELETE` defect by name, and restoring it turns the run red — which is the measurement that says this check would have caught what every green suite missed. It runs in the `console` job, which grew a Postgres service rather than becoming a job of its own: the expensive half, installing a browser, was already paid for there. **And the screenshot pass photographed a bundle that was two commits old and reported success.** A `pnpm build` had failed on a type error, leaving the previous `dist` in place; the run rendered every screen, found no page error and no missing fixture, and none of the changed code was in the page at all. "Test what you write by running it" has a corollary — run the artifact the source produces *now* — so the script compares the newest file under `packages/admin/src` against the newest under `dist` and refuses rather than rendering. Checked by touching a source file and watching it refuse. **A flake failed the 0.19.0 release, and the response is two checks rather than one fix.** `second-factor-live.test.ts` read the wall clock three times across one case — confirm, sign in, replay — and a TOTP step is thirty seconds while the case takes two. So about one run in fifteen crossed a boundary between the second read and the third, computed a code for a step that had never been spent, and watched the server correctly accept it. The assertion said "a spent code is refused"; what it asked was "these three clock reads landed in the same window". Green on the pull request, red on the merge that **was** the release: nothing published, nothing tagged, four images unbuilt. The worse half is that the class had already been diagnosed **in the same session**. `hybrid-live.test.ts` was found to be a coin flip an hour earlier, fixed, and its commit message names the property in as many words — a test whose claim depends on something it does not control. Finding one instance is not a licence to repair the instance, which this file has said twice; the sweep did not happen and the release paid for it. So `lint:test-clock` closes the mechanism and `scripts/flake-hunt.mjs` closes the class. The first **discovers** its subject rather than naming `totpStep` — every export whose instant defaults to `new Date()`, of which there is one, so the next helper written that way is covered on the day it exists. It refuses outright if it finds none, because a check with nothing to hold must not report green. It deliberately does **not** ban `new Date()` in tests: twenty of the twenty-five clock reads here are an expiry sixty seconds out used within milliseconds, and flagging those would report twenty things and name none of them. The hunt is the part no static rule can do, because the hazard is the ratio between a window and a duration. It runs the suite N times and reports any case that failed **sometimes** — every time is a broken test the ordinary suite already catches. Nightly rather than per pull request: a flake is a property of `main`, so it is looked for on `main`'s clock, and a find is a red scheduled run somebody reads in the morning rather than a release nobody can undo. **And the first hunt reported a second flake that did not exist.** Eight cases failed in runs 11 and 12 of a twenty-run background pass — and those runs were executing against a tree being edited at the time, since the release's defect had been injected into that same file to demonstrate the hunt. The failures were contamination cascading through a shared database. That is this file's own recorded mistake — measuring damage this session caused and attributing it to the repository — arriving a second time, and it was one report away from being written down as a finding. Twenty-five runs on a clean tree: no case failed in any of them. **An organization can require a second factor, and the core's half of that is a sixth extension point.** Both kinds are individually opt-in — a person enrols one because they decided to — and an `org_admin` had no way to require one at all. The model-offers-it-and-the-product-gives-no-route shape, on the surface that decides whether a stolen password is an account. `registerSignInGate` is that point and the policy is a module, on the boundary's own test: a single developer on a laptop does not need to force themselves to use a second factor, and an organization mandating one is what a security team buys. **It is consulted inside `issue`, which is the one place a session is minted.** There are four paths from a verified credential to a session — a password with no factor asked for, a completed second factor, a spent refresh token, and a password change — and a check placed beside them is a check the fifth forgets. That is this file's most repeated defect, so the gate goes where all four already arrive and `issue` returns a union, which turned every call site into a compile error until it said what it does with a refusal. A **renewal** is gated deliberately: without it a policy turned on while people are signed in does nothing for any of them for as long as they keep renewing, which is the same case a delegation's `disabled` check exists for. **`enrol` is the verdict that makes such a policy usable, and it is most of the work.** Enrolment lives under `/v1/me` and needs authority, so a gate that could only refuse would lock out everybody who had not enrolled on the day it was turned on, with `psql` as the route back. So the core answers with an **enrolment challenge**: audience-separated from an access token exactly as the sign-in challenge is, carrying a purpose claim neither can spend as the other, and reaching four routes rather than the seven a session reaches. Not the listing, because a caller who has proved nothing is not owed an inventory of the account; not the removal, because taking a factor off under a mandate to add one is what somebody holding a stolen password would do. Confirming through it hands back the recovery codes **and** a session, because being made to enrol and then asked to sign in again is the moment a person gives up. The door sits **behind** `authenticate` rather than in front of it, and both reasons are worth keeping. Running it in front made every ordinary request to that path pay a JWT verification for a token it was not carrying — found by running the suite, not by reading. And behind it the door can only ever be reached by a credential the API has already refused, so a route that forgets it answers `401` and never `200`. A design that instead minted a real access token and asked every other route to refuse it would fail the other way, and on an authorization boundary that is the dangerous direction. `admitSignIn` deliberately does **not** short-circuit the way `admitIngest` does. There, one refusal is the answer and asking the rest cannot change it; here `refuse` outranks `enrol`, so a gate answering the weaker verdict while a later one would refuse must not be what the caller acts on. Every gate is asked, and a `refuse` stops the scan because nothing outranks one. **And the half about SSO needed no code, which is the finding rather than the feature.** "Require a second factor **or** SSO" reads like two things to build. An `AuthProvider` principal presents the identity provider's assertion as its credential on every request and never mints a session here — checked by reading `auth.ts`, where providers are consulted on the *request* path — so a gate on `issue` cannot see them and does not need to. The second half holds by construction, and the honest response was to write that down rather than build a branch for it. **A case written to prove the purpose separation could not fail.** It asserted that an enrolment challenge is refused by `completeSecondFactor`, on a `Login` constructed with no second-factor store — so the method returned on its `gate === undefined` guard one line before it read the challenge at all, and the case stayed green with the purpose comparison deleted. The projection was narrow enough that it could only ever pass, which is the transport-parity defect and the `location.hash` defect arriving in a case written this same day. It is asked of a `Login` that has a store now, in both directions, and both restorations were measured: the old version green, the new one red. **And the console had to learn the answer, or the point would have been a surface nothing could act on.** That is the defect `GET /v1/auth/methods` exists to prevent — a screen offering what the server refuses — arriving from the other side: a server offering what the screen cannot use. So the outcome runs the whole way out, and every step of that was a compile error rather than a choice. The SDK carries it on **four** methods, and `enrolmentFrom` is one reader because four copies of the same three fields is four chances to read `access_token` off a body that has none and store the string `undefined` as a session. `#renew` is where that would have been silent: a renewal answered by a gate carries no tokens, so the seam drops the refresh token, tells `onSignInGate`, and lets the `401` surface — the session really is over, and the spent token is not redeemable. `confirm` and `finishWebAuthn` return `{ recoveryCodes, tokens }` now rather than a bare list. `tokens` is present only through the enrolment door, and making it an object turned both call sites into compile errors instead of adding a field every existing caller would ignore. **Two different `403`s reach `POST /v1/me/password` now**, and the SDK matches on the problem **type** rather than the status. It swallowed every `403` as "wrong current password", so a policy refusal would have been reported as a typo and the person would have retyped a password that was correct. A status is too coarse to carry two facts; a stable `type` is what RFC 9457 has for it. The console's screen shows the gate's own words, offers whichever kinds the installation has, and — the part worth naming — **shows the recovery codes before the console opens**. They are printed once, and signing somebody straight in would have thrown away the only thing that gets them back after a lost phone, which an administrator deliberately cannot undo. `scripts/sign-in-gate-e2e.mjs` is what says this works, and it registers a **real gate** rather than stubbing a response: password, enrolment step, secret, code, ten codes, Continue, console — with the core's `issue`, the challenge, the narrow door, the SDK and the screen all real. Every suite under it proves one half against a stub of the next, and mocks agree with whatever they were written to. Deleting the console's one branch reproduces 0.19.0's state exactly and the run names it. Two of its own assertions were wrong first, and both are the harness failing in the shapes the product does: it read `nacre.token` where the console writes `nacre.admin.token`, reporting no session on a console that had plainly opened; and it cleared `sessionStorage` without reloading, so the signed-in screen stayed up and the sign-in form it then looked for was never going to exist. **A published credential could take itself away from everybody who holds it, and that was live on the public stand.** The two demo logins the front door prints could each enrol a second factor and change their own password — which is the password on the page. Either locks out every other visitor **permanently**: an administrator deliberately cannot remove somebody's second factor, so the only repair is to reissue the credential. Found by asking the running stand rather than by reading: `GET /v1/me/second-factor` answered `200` with both kinds, and `POST /v1/me/password` answered `403 the current password is not correct`, which is a live route waiting for the password that is printed. Until 0.19.0 a deployment had an accidental guard — no `NACRE_2FA_KEY` meant the whole surface answered `404`. WebAuthn needs no key, so it went away, and `docs/upgrading.md` said in as many words that a switch to turn the feature off would be "a switch whose only effect is to make accounts easier to take over". That is true of an installation and false of an account: the thing being protected here is already public by design. So the property is a column on the row it is about — `users.shared`, migration 0032 — and not a setting. A shared account has **no `/v1/me` credential surface**: no second factor, no password change, no reset link, all `404` like a service account and a delegation there. An administrator still sets its password, which is how whoever published one rotates it — the credentials are *administered* rather than held, which is the whole of what the column says. It cannot be cleared afterwards, because clearing it on an account whose password is public reopens the surface to whoever holds that password. Three things hold it, and they are deliberately not three copies of one check. A **trigger** on `user_second_factors` refuses the row whichever surface issued it, so a route added later and written without the check is a `500` rather than a lockout. `Login.changePassword` refuses **before** verifying the current password — checking it first would make the endpoint say whether the published password is still current, and the point is that it is public. And the routes answer the `404` a caller can read, through one predicate rather than two spellings: `holdsOwnCredentials` is the same question a service account and a delegation were already being asked, with a third class added to it. `lint:me-credentials` is the repair rather than the two edits. It **discovers** the `/v1/me` credential routes rather than listing them, so the next one is covered on the day it is written, and refuses outright if it finds none. Both refusals were produced: a route that stops asking names that route, and a surface that moves out from under the pattern says so instead of passing. `GET /v1/me` carries `holds_own_credentials` so the console can leave those controls **off** rather than drawing ones that answer `404` — `GET /v1/auth/methods`'s rule applied to the account instead of to the installation. The Security screen's "not available on this installation" message would otherwise have blamed the deployment for a property of the account, sending whoever read it to check a key that is set. **And the console could not make one**, which is the same hole one surface along: the column, the endpoint, the SDK and `nacre users create` all carried `shared`, and the New user dialog sent three fields that did not include it — so an administrator's route to a published credential was a command line or `curl`. The box is on that dialog now, and what it says is written in terms of what goes wrong without it rather than in terms of the column. `shared` is in the **listing** too, beside `sso` and `disabled`, and that is not decoration: it is fixed at creation, so an administrator who ticked it has no other way to see it again — and it decides whether the person on the other end can hold a second factor at all, which is exactly the kind of thing a screen has to be able to say. **The one screen that hands a value over had no way to hand it over.** "Add an authenticator" printed a thirty-two character base32 secret in a box with nothing to press — `user-select: all` is a selection and not a copy, and it wants a keystroke the person holding a phone does not have — and truncated the `otpauth://` link to 48 characters with the rest in a `title`, which is a tooltip and needs a pointer, on the one string whose whole purpose is to be opened *on a phone*. Every id on every other screen has had a copy control since the console was rendered at 390. There is a **QR code** on it now, and it is encoded here. `packages/admin/src/qr.ts` is byte mode, error correction level M, versions 1 to 40 by capacity, and no dependency: this package's one runtime dependency is the SDK, the page is `script-src 'self'` so a CDN is not an option, and the thing being drawn is a credential. The obligation that comes with writing one is checking it against something that did not come from here — `jsqr`, pinned, a dev-only dependency of a private package, fed the matrix as pixels with its quiet zone. Asserting on the matrix would have pinned whatever the encoder produced on the day it was written. It found three defects before the first symbol was scannable, and one of them would have shipped a picture no phone could read at exactly the length this feature uses. The format-information strip was placed with x and y swapped, and was written without being *reserved*, so data was laid into it and then masked. And the alignment patterns were excluded by "this module is already spoken for" rather than by index — position 6 is the timing line, so from version 7 every legitimate pattern sitting on it was skipped, and an `otpauth://` URL is version 7. Restoring that one fails the otpauth case and every case above 120 bytes while the short ones stay green, which is the measurement that says it is about alignment. Black on white is the one deliberate exception to `lint:tokens`: a QR is read by a camera and a threshold, so its two values are a scanning requirement, and inverting it for a dark theme is how a symbol stops working. The recovery codes can be **saved** as well as read. They are printed once and an administrator deliberately cannot restore them, so that dialog is the only moment they exist outside a hash. The file goes through Web Share where the browser has it — the public stand learned from an iPhone that Safari does not save an ``, it *navigates* to the blob, and here a reload does not bring the codes back. Two checks came out of that work and neither is about QR codes. **A dialog whose action you cannot reach is a dialog you cannot finish**, and that dialog now carries a picture, a secret, a link and a field before Confirm — a `` scrolls only because the user-agent stylesheet says so, and a `max-height` written here would take it away. The check's first version set `scrollTop` and measured, and `overflow: hidden` still honours a *programmatic* scroll: restoring that rule left every button comfortably inside the viewport and the check green. It had measured a scroll no thumb can perform, which is the narrow-projection defect this file names three times, produced inside a check written the same hour. **And the stale-bundle guard knew about one of two inputs.** It read `packages/admin/src`, so `packages/admin/public/admin.css` was invisible to it — and the stylesheet is where every layout defect that script exists to find lives. Found by falling into it: a rule added to `.dialog` to prove the new check could fail changed nothing, because `dist/` still carried the old stylesheet. What counts as source is discovered now, out of the bundle's own sourcemap, so the SDK's sources are covered without being named, and a missing map is a refusal. **The messages this product sends are in the brand, and both parts come from one description.** They were plain text, on an argument written in `mail.ts`'s own header: that each is one sentence and one link, that an HTML part doubles what has to be escaped, and that a link in an HTML mail is the shape a phishing filter and a suspicious reader both distrust. Only the last was ever about the reader, and it is answered rather than avoided — **the link is shown as its own URL**, beside the button, so somebody can see where it goes before pressing it. That is more than the plain-text version offered, where the link was the only thing on its line and nothing said where it went. The first two are answered by there being one description and one escaper: a `Message` is a list of blocks and `message()` renders both parts. A `Message` carries a brand only that module can apply, so a message cannot be assembled by hand with one part missing — it does not typecheck. That is what found the **fifth** message while the paragraph claiming there were four was being written: the count had come from `mailer.send` call sites rather than from the messages. `packages/api/src/messages.ts` is all of them in one file now, which is also what lets the preview render the real ones rather than retyping them. `lint:mail-palette` is the repair for the one thing an email cannot do. A mail client resolves no custom property, so those colours are literals — the only ones in this repository — and the check reads them back out of the brand mirror the console ships, following one level of `var()`. A colour that moved in the brand fails here rather than sending last year's teal to everybody who forgets a password. Each of its three refusals was produced: a drifted value, a colour naming no token, and the palette moving out from under it. The renderer is asked what it **computes to**, not only what it says. `scripts/mail-preview.mjs` renders the product's own list at 390 and reads the paragraph's colour, size and leading back out of a browser — which is what caught the defect a string assertion never would have: the brand's font stacks are spelled with double quotes, and interpolated into `style="…"` that closes the attribute at the first character of the family name. Every declaration after `font-family` was dropped. The markup still parsed and nothing threw; what was lost was the size, the leading and the colour of every paragraph. It runs in the `console` job, where a browser is already installed. Writing it also turned up the console's `.warn` reaching for `--n-deny`, which the brand reserves for one of four permission semantics and says not to reassign. `--n-error` is the same hex, so the correction is zero pixels and one fewer place where a permission colour means something else. **And the harness that was parked has landed.** 0.22.0's pull request said the seed's four-state check "hangs, and a check that cannot run must not be committed", and that it would be finished separately. It is `lint:demo-seed` now, and what was wrong with it is worth more than the check. It served the stub API with `createServer` in the same process that ran the seed with `execFileSync` — which **blocks the event loop**, so the child asked `/v1/ready` and nothing was ever going to answer. Every run died on `waiting for the API to be ready`, which is the harness *accusing the script* of a fault entirely its own. That is the shape this file records twice already: a smoke test that never sent its document and then failed accusing the product, and a stand check that could not parse a page and reported the deployment down. The stub is a process of its own now and prints the port it chose rather than being given one. The script under test is a **copy** with two substitutions — the two absolute paths the seed invokes point at stubs — and both are required to apply. A rename in the seed fails the check by name rather than leaving the stubs uncalled and every assertion passing against a script that ran nothing, which is the blanket-exclusion shape `contract-surface.test.ts` was fixed for. Four states: fresh with a failing ingest, a resume, an already-seeded run that must call nothing at all, and the ordinary path. Restoring the shipped defect — the summary written after `load_corpus` rather than before it — fails the first two and prints the old `Start over: down -v` in the output, which is the measurement that says this would have caught what the `demo` job could not. That job runs a real embedder against a real corpus; it cannot ask what is left behind when the ingest inside it fails. **The console offered a platform administrator three screens that answer `404`.** `administers(auth)` in the API is `org_admin` and nothing else — a `platform_admin` administers the *installation*, and every endpoint scoped to one organization refuses that role in both directions, which this file already says. The console decided what to draw with `role === 'org_admin' || role === 'platform_admin'`, which is the obvious derivation and is the wrong one, so Grants, People and Service accounts were in the nav for exactly the person most likely to sign in to check something. Found by photographing it. The fixture is a `/v1/me` answering `platform_admin` and the picture is the whole finding — the fourth defect this console has surfaced that way. `GET /v1/me` reports **`administers`** now, and it is not a second copy of the predicate: it *is* the predicate, the one every gated handler calls, saying what it will do. Same rule as `GET /v1/auth/methods` and `holds_own_credentials` — ask what the server will do rather than re-deriving it in a browser. The SDK falls back to `role === 'org_admin'` against an older API, which is the conservative half of the derivation it replaces and never the half that offered too much. `check-admin-gate.mjs` had `packages/admin/src` in its roots the whole time and could not see this. Its pattern is bound to `auth.` — correctly, and for a reason written at it: `fields.role !== 'member'` is validating a value, not gating a request. The console's caller is called `me`, so the one instance it covered a browser for was the one spelling it could not match. Widening it to any `.role ===` would flag the People screen legitimately reading a `` asking you to choose said `pick a organization…`. The article was built by hand from the noun, and `organization` is the first vowel-initial noun any caller passed. Computing it from the first letter would fix that and break `user`, which the same control takes: `an user`. English articles are not a function of spelling, so there is no article — the label beside the control already names the thing. And a **disabled button looked exactly like a working one**: there was no `:disabled` rule in `admin.css` at all, so a disabled primary button rendered in full teal and a press did nothing. The rule that breaks is written three lines above `.picked` and is about a select with one option — *"a control that cannot be operated still invites operating it"* — held in one place and nowhere else, which is this file's first paragraph. `lint:admin-layout` gained a fourth rule for it, and it asks **every** `.btn` variant rather than the base class: a variant that paints its own background and brightens on hover needs the disabled form to win there too, so the check pairs each `:hover` that paints with a `:disabled:hover` that covers it. Both refusals were produced — the rule removed entirely, and the hover half removed alone, which names all three variants. **Writing it exposed that rule 2 could not fail.** The hover-reveal rule collected every `:hover` rule with a non-zero opacity and never asked whether anything was hidden — and no `:hover` rule in this stylesheet set an opacity, so the list was always empty. The first legitimate one, `.btn:disabled:hover`, was reported as a control a phone can never reveal. It requires a matching `opacity: 0` rule now, which is the relationship the other half of the same rule already used. The narrow-projection shape this file names three times, in a check written against it. **A second full audit swept the tree, and the worst of it was on the paths a suite structurally cannot see.** The refresh path could deadlock the whole API: `issue`, inside the transaction the refresh and password-change paths hand it, awaited a `required()` that opened a second pool connection — and `createPool` sets no `connectionTimeoutMillis`, so twenty concurrent refreshes each held one connection and waited forever for a twenty-first. The connection is threaded now, and a `max: 1`-pool test proves a refresh completes where it used to hang. Delete → re-ingest identical content left a live document invisible forever — resurrection matched no requeue predicate, so the row went back to `indexed` while its points kept the tombstone's `deleted: true`, invariant 5's mirror image answering `unchanged: true`; resurrection always requeues now, and resets the collector's columns, or the next delete of a once-purged document never reached the sweep. `source_type` moves with `source_ref` — written once at INSERT, a document re-sent as a different kind was dispatched as the old one, up to indexing an object key as the document body. The collection copy had no claim: every replica's tick started the same copy, which begins by deleting its target — it is a lease plus a fencing token now, renewed from the copy's own progress, `finishCopy` refuses a token that is no longer the holder's, the embedding pass holds during a copy like ingest does, and `repairAfterCopy` requeues or re-tombstones what moved under the scroll. The background clocks tick while the queue is busy — they lived only in the idle branch, so a sustained backlog starved reaping, collection, the reindex an operator watches, and retention. The password-reset endpoint was a timing oracle — it awaited the SMTP round trip before its `204`, so the one route needing no credential told an attacker which addresses have accounts; the answer now comes before the work, proved by a stub whose send never resolves. A locked TOTP factor renewed its own lock on every attempt, the correct code included. A malformed percent-escape in any path segment was a `500` and a spurious `error` audit row; segments decode inside `pathMatch`, eslint refuses a bare `decodeURIComponent`, and a contract-wide case drives `%ZZ` through every parameterised operation. A cursor from one collection pasted into another died in a type cast; the id shape is a required argument now. **And `ping` was answered on STDIO and 404'd on Streamable HTTP for the whole life of both**, because the parity suite's table guard compared `SHARED` against a literal — nothing in the file ever read either dispatcher, and the literal even pinned the table shut: adding `ping`, the fix, made the guard red until the literal moved too. The guard reads both dispatch switches' own case arms now, the hand-built `CallToolResult` copies became one builder in `results.ts`, and `check-test-clock` matches the inline `new Date()` spelling that behaved exactly like the flake it exists against. The screenshot pass's `/v1/me` fixture was a body no server sends, and nothing anywhere held the SDK's `administers` mapping in the one direction where the field and the legacy derivation disagree — `client.test.ts` pins both directions now, because no screenshot can separate them where they agree. Every fix in that round was measured red with the defect restored and green with it in place — including two checks whose own first versions could not fail and were caught the same way, in the same session that wrote them. **A tenant admin could steer the shared worker at the internal network, and every listing lied by omission at scale — 0.26.0 closes both.** `POST /v1/embedding-providers` is `org_admin`-gated, and that role is the threat model. In the open core there is one organization, so its `org_admin` is the operator — they own the documents, and an earlier version of this note calling it "document exfiltration" was wrong: there is nobody to take the caller's own text from. The real hole is **multi-tenancy**, where an `org_admin` administers one tenant and not the installation: pointing the worker at `http://169.254.169.254/…`, the API beside it, or the vector store (no per-tenant authorization) is an SSRF from a privileged position — blind at least, plausibly a partial read oracle through `documents.error` — and cloud metadata credentials are installation-wide, so it is a tenant→installation escalation by somebody entitled to one customer's data and nothing else. The `platform_admin` installation-default screen is a different, un-gated path, because that role *is* trusted with the internal network. `admitEmbeddingEndpoint` is the parser's private-address guard on the surface that *steers* a request rather than fetches a URL: a public `https://` address, or the embedder the operator configured (matched by origin against the global provider rows, which is what a tenant may read and all it may read), and nothing else. A public name resolving to a private address is refused by checking every address it answers with. Both refusals were produced; the allow-list being the installation's own rows under RLS is what the live case against a real PostgreSQL proves. The guard is hardened past a range check: a tenant endpoint must be a **hostname** and not an IP literal — which closes decimal/octal/hex obfuscations a range table never sees — the blocked set covers TEST-NET, 6to4 anycast, NAT64 and the IPv6 special ranges, an operator's second internal embedder is trusted through `NACRE_EMBED_ALLOWED_HOSTS`, and the create-time check is paired with `redirect: 'error'` on all three model fetches (ingest, search, rerank), because following a redirect at fetch time is how a create-validated public host reaches an internal one. `lint:embed-egress` holds that last property across the three places rather than the three edits. `NACRE_EMBED_TENANT_PROVIDERS=false` removes the surface rather than bounding it, which is the answer to "no `org_admin` UI creates a provider — is this even reachable?" The route is reachable by an `org_admin` credential through the API with no console button at all, so the guard stays right; but on a managed platform, where an `org_admin` is a customer and embedding is a service the platform provides, the whole route answers `404` and there is nothing to guard. Default `true` is the open core, where the `org_admin` is the operator and tightening the *role* would reintroduce the `psql`-only shape this repository keeps closing — so the switch is a flag, not a role change. The listings were the other half, and the worst was silent. Every `.list()` in the SDK fetched **one page and dropped the cursor**, so a console showed the first fifty of everything and said nothing — and on the grant list, "who can see this patient" answered from a truncation that looks complete is a security surface answering a narrower question than the one asked. The helper walks the cursor to the end now, or throws past a bound naming the filtered directory — never a partial set that reads as whole. `list_layers` on MCP had the same shape from the other side: it returned every layer the plan reaches, no limit, no order, no cursor, which on an installation at the scale layers are sold for is a million rows in one tool result, and the search description interpolated that whole catalog on every `tools/list` **and** `tools/call`. It pages now, the description names a sample, and dispatch reads a name-and-permission catalog that needs no listing at all — a per-call cost paid by every caller, read by nobody, gone. **A transient failure was a permanent verdict, on the first attempt.** `markFailed` wrote `status = 'failed'` for anything the worker caught, and `attempts` and `NACRE_INDEX_MAX_ATTEMPTS` were read by exactly one caller — the reaper, which handles a worker that *died* mid-document. The far commoner case had no retry at all: an embedder restarting, a Qdrant that blinked, a parser timing out, and every document in flight was recorded as failed forever. Nothing retries `failed`, so they sat there until somebody re-sent the bytes, while the layer went on answering searches out of whatever had indexed — quietly worse, with nothing in a log a person reads. The twenty-six-out-of-fifty shape, arriving without a threshold to notice. `classifyIngestFailure` has known the answer the whole time. `unavailable` is documented "re-sending later may work" and `too_long` "re-sending will not help", and it was asked only by the API, to explain a failure to a caller, and never by the worker, to decide what to do about one. Declared, correct, and read by one side. `isRetryable` is that documentation made executable, in the file that defines the reasons, so the two cannot drift. `retry_after` (migration 0033) is what makes a bounded retry a bound. Without a "not before" time an embedder down for thirty seconds burns all five attempts of every queued document inside one poll — five times the load on the service that is already failing, and a permanent verdict anyway. NULL is claimable now, which every existing row and every fresh document carries, so there is no backfill. `quota` deliberately does not retry: its remedy is an administrator raising a limit, and retrying against a full one is load with no chance of success. `internal` does, and that is the one judgement call — the honest prior for an unknown error in a distributed system is that it might not happen twice, and the bound is what makes the guess cost five spaced attempts rather than forever. The status and the window are chosen by **one** `UPDATE`, from the row's own `attempts`, because reading the count and then writing a verdict is two statements with a window between them and two workers in it would each read four against a bound of five. So what is under test is a `CASE` and a `make_interval`, which is why the cases are against a real PostgreSQL: a fake would assert my own arithmetic back at me on a path where being wrong is silent, and the two ways to be wrong are documents that retry forever and a permanent verdict on the first blip. Restoring the shipped behaviour names the three cases about retrying while the five about permanent failure stay green, which is the signature that says the permanent half was always right. `POST /v1/documents/{id}/retry` is the operator's way out of what is left. Re-sending through `POST /v1/documents` requeues a failed document and always did — `REQUEUE` has carried `status = 'failed'` since it existed — and what it needs is the *document*. The caller who most wants to retry is the one who no longer has it: an integration that posted those bytes from a file that has since moved, after a quota somebody has now raised. The row still knows where its content is. `write` on the layer, the same permission as ingesting into it; `204` and never the document, because rule 6; `attempts` reset to zero, because that count measures one run against one deployment and the deployment is what changed. A document that has **not** failed is `409` and not `404`. The caller may write it and is looking straight at it, so `404` would send them hunting for something on their own screen — and the refusal is about the document's *state* and stops applying the moment it fails, which is the last-administrator `409`'s shape rather than a `403`'s. A boolean port would have collapsed the two; `RetryOutcome` is three values, and the SDK matches on the problem **type** rather than the status, because a status is too coarse to carry two facts. The claim's own predicate is one exported string both the query and the case use, and that alone does not close it: `CLAIMABLE_NOW` stops two spellings drifting and cannot stop the claim dropping the clause, which the live case would not see, because it asks the constant. So the subject is **discovered** — any statement in the worker asking for a `pending` document has to carry it, and a run that finds none refuses. Both were produced. **And the counter an operator reads was one behind, found by running the released image.** `Claim.attempts` says in its own documentation "including this claim — the claim statement increments it", and it did not: the `SELECT` reads the row and the `UPDATE` increments it afterwards. Both readers were wrong in the same direction — the worker logged `attempts: 0` on a *first* attempt beside a `max_attempts` on the same line, and the backoff's exponent started a step low, so the first two attempts shared one thirty-second ceiling instead of doubling. The bound was never affected, and that is why nothing failed: it is not computed from this field. `recordFailure` compares the row's own post-increment count inside the statement that writes the verdict, so the number of attempts a document got was always right. What was wrong was every number a person reads — which is the class this file already names as a claim in a comment that stopped being true, arriving in the same change that wrote the comment. Found by pulling `ghcr.io/nacre-work/nacre:0.25.0`, starting it against a parser that was not there, and reading its log: `attempts: 0`, then `1`, then a permanent verdict at `2`, on a document the row said had been tried three times. That is this repository's own rule about running the artifact rather than the source, one step further out than it usually reaches — the artifact had already shipped, and every gate was green when it did. `claimNext` moved into `claim.ts` to be askable at all, for the reason `retry.ts` beside it gives: `main.ts` calls `main()` at the bottom, so importing anything from it starts a worker. Moving it is also what made the queue check refuse — it named three files and the claim went into a fourth — so that check discovers the package's sources now instead of listing them. A check that has to be edited when code moves is a check somebody edits the wrong way. **Two `SECURITY DEFINER` functions could be shadowed by a temp table, in the two migrations written to prevent exactly that.** 0010 pinned `search_path = pg_catalog, public` on `bump_groups_version` with a paragraph explaining the hazard, and 0012 wrote the same on `prune_audit_events` — and omitting `pg_temp` does not exclude it, it moves it to the **front**, ahead of `pg_catalog`. The pin had the opposite of its stated effect. 0032's function, written later, already says `public, pg_temp` and is correct; nothing compared the three. Not remotely reachable — it takes the ability to run SQL on the session that then calls the function, which is possession of `nacre_app` or `nacre_worker`, and 0010 was right to call itself defence in depth. What it would have cost from there is what those migrations were protecting: a shadowed `organizations` means `groups_version` never moves, so the effective-principals cache never invalidates and a revoked grant goes on being served, which is the invariant 4 failure 0010 exists against. Measured both ways with the product's own trigger against a real PostgreSQL, which is the part worth keeping: with the shipped pin and a temp `organizations` present, an insert into `groups` moved the **shadow** and left the real row alone; with `pg_temp` named last, the real row moved and the shadow did not. Migration 0034 is an `ALTER` rather than an edit, because migration text is checksummed. `lint:definer-path` is the repair — it reads every `CREATE` and every `ALTER` in file order, so the *last* setting decides, which is what a forward-only tree needs. Its own first version found **two** subjects where the tree has three: `bump_groups_version` becomes a definer by a later `ALTER`, and the one it could not see is one of the two 0034 repairs — so it would have passed half a fix. Four refusals, each produced. The rest of that audit found nothing to fix, and saying so is most of its value: every request-reachable value reaches SQL as a bound parameter, every interpolation is a file-local literal or a `$n` placeholder, `SET LOCAL ROLE` carries its identifier guard at all three sites, and there is no `quote_ident`, no dynamic `EXECUTE`, no `OFFSET` and no caller-built `ORDER BY` anywhere. Nor is there a filter-injection path into Qdrant: the metadata key regex excludes `.` and `[]`, so `meta.${key}` cannot address another field, and a caller entry can only ever become a `must`. **What retrieved text does to a model is the boundary that had no sentence.** Nothing filters a document's contents and nothing should — a payload inside a document the agent may legitimately read is, to every control here, a correct answer. But `docs/mcp.md`'s care about who may read what reads as a broader safety claim than is being made, so it now says plainly that a chunk is untrusted input to the calling model, that rule 6 makes an ingest-only service account a sufficient position from which to plant one, and that the delegation's permission ceiling is the control that bounds the damage — because it bounds what an agent can *do*, which is the half that is actually boundable. **Every tool call from a current client failed, and the catalog looked fine.** The 2026-07-28 revision makes `resultType` a MUST on every result and this server wrote it on `server/discover` alone — the one result the parity suite and the surface tests quoted. A client speaking that revision listed six tools and then refused every `tools/call` answer as malformed, so an agent saw its layers and could search none of them. Found by a real client (Claude Code) on the four sales agents' connectors, not by a suite: every case asserted the shape this server writes, which is the fixture-agrees-with-code defect again. `COMPLETE` is one constant the four builders use, and `result-type.test.ts` asks each builder. 0.26.1. The tools carry MCP `annotations` in the same release — the type requires them, because without them a client assumes the worst of every tool and confirms a search as carefully as a delete. And CI went red on `main` for a reason outside the tree: `minio/minio` left Docker Hub. The object store is Chainguard's build of the same server now, in the four workflows and the `full` profile alike. **The MCP server is `@modelcontextprotocol/server` 2.x now, and the parity argument is structural.** Both transports hand-built every result — and diverged on `permission`, on the capability set, on a cache hint, on `ping`, on `initialize` negotiating versus announcing — because two dispatchers each remembered the protocol separately. `factory.ts` builds one `McpServer` for the caller the token names; Streamable HTTP builds one per request and STDIO one per connection, and the protocol — the two eras, the `_meta` envelope, the mirrored headers, `resultType`, the cache hints, `-32020`/`-32022`, the legacy counter-offer — is the SDK's. What stays this repository's is everything before a frame is read (`Origin`, CORS, the RFC 9728 document, the `401`, authentication through the same `authenticate` as REST, the tenant-override refusal, the rate limit) and the one wrapper that keeps a thrown error's message off the wire. The parity suite's last case refuses a second factory, because that is now the only way to diverge. Three things the spike found, each of which the hand-written server read the other way. A 2026-07-28 request **must** carry the envelope and the mirrored headers, and the SDK's own client does; the lenient reading was right about the legacy era and wrong about the modern one, and the conformance page says which is which. The SDK answers `406` to a POST that does not name `text/event-stream`, which `curl`, `fetch` and every hand-written check here do not — so `Accept` is filled in when the client asked for no stream, since this server answers JSON either way. And the SDK's stdio transport closes itself the moment its stdin ends and refuses to write afterwards, while a script and the parity suite close the pipe with the answers still in flight: the wrapper feeds it a stream that never ends and forwards the close only once every request has been answered. Found by running it — the first version answered nothing and resolved never. **And the STDIO suite had been written to the dispatcher it replaced.** Five of its ten cases went red on the SDK and every one was the fixture: a bare `initialize` carrying only `protocolVersion`, which no client sends and the schema refuses; a `server/discover` with no envelope, which is a legacy frame and is rightly `-32601` from the legacy server; a `search` with no `query`, answered `isError: false` because nothing on that path had ever validated arguments — a relaxation of exactly the kind the file's own header says the local surface must not have; and a `-32700` frame with `id: null`, which MCP says is not a response at all, asserted onto a stream the same file promises carries nothing else. The SDK's reader skips an unparseable line and reads on, so the property that survives is the session, and the case says so. The e2e went red once beside it for a race the rewrite made visible: `web` starts after `mcp` is *started*, not after it listens, and the front door's first request landed on a 502 and was read as "not proxied". It waits now, as it already did for the API and the console. What the SDK does **not** do, measured rather than assumed: `resultType: "task"` from a tool is refused and `tasks/get` is not routed in the 2026-07-28 era, so the tasks extension is not a registration away; `ext-tasks` is the client half. An unknown tool is its JSON-RPC `-32602` naming the tool, which `docs/mcp.md` argues is not a leak; a failing tool is still one answer. **A file reaches the index without passing through a model.** An agent that holds a file cannot put it into `ingest_document`: a tool argument is a string the model has to emit, which is the file retyped through the context window — paid for twice, and for anything a model cannot faithfully reproduce, not the same bytes. `request_upload` (MCP) and `POST /v1/uploads` (REST) mint a **ticket** instead, and whoever holds the bytes — a shell handed the `curl` line, an MCP App's file input, a script — sends them to `POST /v1/uploads/{ticket}` as the raw body. The ticket is the capability: minted on `write` with the same `404` for unwritable and absent, single-use by `GETDEL`, five minutes, queued **as the minter** with the write checked again on arrival, and **failing closed** on a Redis that does not answer — against the grain of the rate limiter beside it, because this one is an authorization control. The redeem is the one door on the API answering `Access-Control-Allow-Origin: *`, and that is a decision rather than a relaxation: an MCP App's file input runs in a host's sandboxed iframe whose origin no deployment can list, and admitting `*` adds nothing there — no cookie, no `Authorization`, and a page that holds no ticket gets what a stranger gets. Everything else stays on exact match. The bytes get the multipart part's admission through the **same function**, and the outcome goes through the same answer, because the second door arriving is exactly when two copies of "what is a document" start to disagree. The descriptor is shaped after SEP-2631's, so the proposal becomes a second door onto this store when it lands. Two things were decided against on the way. URL-mode elicitation — the tool answering `input_required` with a console page to open — was built as far as a diagram and dropped: it takes the person out of the agent and into a browser, and the MCP App widget does the same job inside it. And the tasks extension was measured rather than planned: SDK 2.3.1's server refuses `resultType: "task"` from a tool and does not route `tasks/get` in the 2026-07-28 era, and `ext-tasks` is the client half, so it is not built. **Three MCP App views ship with the server, and they are the half of the upload that happens inside the agent.** `packages/mcp/apps/` is `upload`, `search` and `layers`: single HTML files, script inlined by the package build because a host's sandbox loads nothing from anywhere, served as `ui://nacre/*` resources under the extension's media type. A view reaches the server through the host and holds no credential; the upload view makes the one request a view makes itself — the bytes to the ticket URL — and is the only one whose CSP names an origin. `upload_file` is the tool that opens it, and it is dropped from the catalog of a modern-era client that declared no `io.modelcontextprotocol/ui` extension, because a panel nobody can render is a tool that does nothing; a legacy-era client says nothing per request and is offered it, since the hosts rendering apps today are legacy-era clients. A host is not in the suite — a stub host agrees with whatever it was written to — so `apps.test.ts` asks the wire: the listing, the media type, the HTML, the CSP per view, and the catalog with and without the declaration. Each view is ~600 kB because the extension's client carries the protocol with it; it is read once per render, through the host. **And the three views were drawn in the host's colours, at three heights.** The first version applied the host's style variables and declared no colour of its own, on the argument that a panel should look like the conversation it is in — and what that produced, rendered, was a `` field, a native file picker and a button each at its platform's own height, in a palette that was nobody's. Reported from the renders in those words. A view is a page of this product and now says so: the build inlines the brand mirror the console ships (tokens and faces, as `data:` URIs, since a sandbox fetches nothing a view did not declare), `shared.ts` carries the console's control vocabulary at **one** height — the file control is a label over an invisible input, because the native widget cannot be sized — and the host decides only the theme. Rendered at 600 and 390 and in the dark theme before being believed; at 390 four columns of a hit did not fit, so a document's id and a layer's description are a second line under the first cell rather than a column, which is the console's own treatment of a value that is an annotation. **And the layers view's More button appended the first page again on every press.** Reported from a real host with the picture: twenty-four rows that were eight rows three times, and "24 layers you may read." under a More button that should not have been there. The view sets `hidden` on the button once the server answers with no cursor, and `hidden` is the user-agent's `display: none` — which the restyle's `.btn { display: inline-flex }` beats, so the attribute did nothing. The console learned exactly this on a dialog field, in a repository this one can read, and the lesson did not travel to the next stylesheet. `[hidden] { display: none !important }` is in every view now, the press is refused without a cursor whatever the button looks like, and `apps.test.ts` holds the rule in every built view — removing it from one names that one. The render harness presses More twice and reads the button's computed display, which is the check that could not have passed before. **That harness was never committed, and the sentence above described it for three releases.** A property held by a script on one machine is held by nobody. It is `packages/mcp/scripts/render-apps.mjs` now, as `lint:apps` in the `console` job: the built HTML in an iframe, the extension's own `AppBridge` as the host, tool calls answered from fixtures, every view at 600 and 390 in both themes — and More pressed twice, asserted, because the claim was made in this file and nothing checked it. **There is a fourth view, the skill panel**, opened by `get_skill` and `list_skills`: the file tree, `SKILL.md` rendered and as source, who wrote the version and whether it carries scripts, and — where `get_skill` says `writable` — loading a folder or a `.zip`. Its Markdown is the console's own module, imported across the package boundary at build time, because a skill is written by somebody with `admin` on one layer and read by people with more, and that module is the one place already made to draw it as text. A second renderer is a second chance to get `javascript:` wrong. The `.zip` goes to `update_skill` as `zip_base64` and is read by the server's one bounded reader: a browser has no `zlib`, and a second reader is a second place for a zip bomb. The first render broke inline code mid-word — `l` / `eave-policy` — because the shared `code { word-break: break-all }` is right for an id in a table and wrong in prose. Found by looking, which is the only way it could have been. **The images are mirrored to Docker Hub, and `ghcr.io` stays canonical.** `docker pull nacre` is where somebody who has not read a document looks first, so the release pushes every image to `docker.io/nacrecontextlayer/` as well, from the same build, at the same tags. Canonical stays `ghcr.io` because Docker Hub rate-limits pulls by account and nothing here reads the mirror back — the chart, the Compose files and every document name `ghcr.io`. The mirror is held by `lint:images` rather than trusted: every `tags:` block has to carry the mirror line beside each canonical one, and the architecture loop has to inspect both registries, because a registry added to one push step and not the next is this file's first paragraph arriving in a workflow. Both refusals were produced. The planner asks both registries before it skips the containers job, because that job is skipped once `ghcr.io` has the tag — so a first attempt that reached `ghcr.io` and failed on Docker Hub would otherwise never be re-run into the mirror. It asks Docker Hub's web API rather than its registry: an anonymous manifest `HEAD` there counts against the pull-rate limit and answered `429` from the first machine it was tried on, and a repository that does not exist answers `401`, which reads as "went private". The commercial image is deliberately **not** mirrored: it is gated by a GitHub token on `ghcr.io`, a second registry is a second credential for the same customer, and the private repositories cannot read the organization's Docker Hub secrets on this plan — `nacre-enterprise` carries `DOCKER_USER` and `DOCKER_PAT` as repository secrets of its own for exactly that reason, to sign its CI's pulls in. `get_document` carries its presigned `source_url` twice now: in the JSON, and as a `resource_link` block, which is the revision's word for "the result is somewhere else" and what lets a client fetch the whole file out of band. One block per call and only there — `search` still carries none, for the reason already written: ten bearer capabilities where the caller wanted an ordering. **An agent that connects already knows how to work here.** That is the whole of [docs/skills.md](./docs/skills.md), and it is built: a skill in Claude's own folder format at three levels — the installation's, the organization's, which replaces it, and a layer's, which is added — carried in `instructions` on `initialize` and `server/discover`, readable over MCP with `list_skills` and `get_skill`, a layer's writable with `update_skill`, and every level through `/v1/skills` as JSON or as the `.zip` Claude exports. An installation nobody has configured hands every agent `packages/core/default-skill.ts`, which says what to search before guessing, what never goes in, and that `queued` is not `indexed`. **A layer skill is visible exactly when its layer is**, and that sentence is the security property: any permission on the layer, `write` included, inside a delegation's narrowing — and one the caller cannot see answers as a layer with no skill, or the listing is a directory of layers. T26–T28, T33 and T34 hold it against a real PostgreSQL, and each was measured red by breaking the rule it names, including a shared `instructions` cache across organizations, which is why `server/discover` is cached `private` now. Every write is an immutable version naming the one it was based on, and the unique key on the version number is what refuses the second of two writers racing from one base — a `409` rather than an erasure both believe succeeded. Going back is a write. A version says whether an agent wrote it and whether it carries scripts, because a skill is instruction every later agent follows, and a document saying "rewrite this layer's skill" is a prompt injection that persists. **Writing the cases found the suite's own numbering colliding.** Three tests of per-layer ceilings had been labelled T26–T28 since they were written, with no rows in `docs/authz.md` — and the skills cases were then given those numbers, so marking one implemented would have been satisfied by a test about something else. The coverage check only ever asked that an implemented case has a marker, never that a pending one does not. It asks both now, the ceiling cases are T36–T38 with rows of their own, and restoring one old label names it. **What every agent is told first is the built-in guide, and no skill can replace it.** The mechanics — `queued` is not `indexed`, a file goes through `request_upload`, the same `external_id` replaces a document, a layer's skill is read before writing there — had been put in the default skill, and an organization's skill replaces that one entirely: the first organization to write "contracts are signed PDFs only" would have taken all of it from every agent it has, while nobody writing that thinks to restate how ingest works. So the line is who knows the fact. What is true of the server is `instructions.ts`, shipped in the release and editable by nobody; what is true of an organization is its skill, which follows the guide and decides conventions and never how permissions work. `instructions.test.ts` holds every tool in the catalog against the guide — dropping one sentence names the tool — and writes an organization's own skill to ask that the mechanics survive it. **The administrative MCP reads** — [docs/mcp-admin.md](./docs/mcp-admin.md). `/mcp/admin` is a second resource on the MCP process with its own audience, `${NACRE_JWT_AUDIENCE}/admin`, chosen when a token is minted from the connection's `surface` (migration 0038) rather than from the request, because a renewal has no request. Its consent is an RFC 8707 indicator naming that path: an `org_admin` only, as the person, no layers and no permissions. Thirteen read tools — people, groups, layers, grants by principal or scope, what anybody can reach computed by the active resolver, skills, connections, the access log and counts over it — and four prompts. Its writes came in 0.34.0, below, and only as proposals: until there was a panel to apply one in, there was no write at all, because one that skipped the person would be the write the design exists to stop. T31 is held **twice, and each half was measured alone**: the audience, which the API and `/mcp` compare exactly, and the connection the token names. Removing the connection check lets a token minted with the administrative audience from an *ordinary* connection through; removing the role check admits a person demoted since consent; using the ordinary audience on `/mcp/admin` turns all four cases red. It follows **no skill** — its `instructions` are its own text, and a skill read through `get_skill` comes back under a notice saying it is under review, because a layer's skill is written by somebody with less authority than the `org_admin` that surface acts for. T39 holds both, and each was red with its rule removed. **`audit_events.client` was in the schema from 0001 and written by nothing.** It is the connection now, `connection:`, on every delegated request on every surface — set once per request in an `AsyncLocalStorage` scope that every write inside reads, rather than at fifty-nine call sites with nothing that knows there are fifty-nine. The question 0.31.1's note had to send to the Connections screen — did an application approve something with its own token — is answerable from the log for anything after it. Writing the summaries found the access log's own shapes disagreeing: an administrative write names `layer_id`, an ingest the `layer` slug, and a search the `layers` and `returned_docs` it answered with. A filter on one shape answers "who read this document" with the fetches and none of the searches that returned it. The filters and the counts read all three now, and the live case was red with the array expansion removed. **The parity suite's "nowhere else" was two files.** Its comment said `registerTool` appears in the factory and nowhere else under `packages/mcp/src`, and it read `server.ts` and `stdio.ts`. It reads every file now, names the administrative factory as the one exemption with its reason, and refuses STDIO importing it. The renders found three things no check could. The administrative consent screen drew itself **twice**, one copy under the other: it awaited `me()` before appending, and the router renders twice, so two overlapping renders each cleared an empty page and both appended. The log fixture had an agent's key acting "through Claude", which no server writes — only a delegation carries a connection, and a delegation is a person. And "through Claude" rendered at 15px under a 12px action, which `columnValuesAgree` named before anybody looked. **A connected application is listed only what its ceiling admits.** A person who approved a read-only connection approved a search client, and that client was listed `delete_document` and `update_skill` anyway and refused each on every call — so the catalog said the client may do what the person had said it may not, and the public stand's own page described a "not offered" marker the server never produced. Found there, by reading the page against the code. The filter is `delegationPermits`, the predicate the request path asks, so the catalog and the refusal cannot disagree; and it is the delegation's ceiling only, because that is fixed for the token's life, while grants move between calls and a catalog listed once per session must not freeze them. It only ever removes. `ceiling-catalog.test.ts` asks the wire, and taking the line out turns three of its four cases red. **The Helm chart lives here now, beside the images it names.** It spent its life in a private infrastructure repository with a version of its own, and that boundary is what every one of its defects crossed: `appVersion` drifted three releases behind the templates, so the console Deployment set a variable the image it named had never heard of; the chart carried a second copy of the embedding adapter's vendor tables that had to be fetched from here on a clock to be compared; and a new workload was named by no NetworkPolicy rule. A self-hoster on Kubernetes wants exactly this file, and the commercial list's "HA Helm charts" named nothing anybody was ever sold. So it is Apache 2.0 under `deploy/helm`, its `version` and `appVersion` are the release's and `lint:chart-version` holds them to the manifests, and the other three checks it carried read the adapter and the templates in this tree instead of over the network, and every release attaches the packaged chart, so `helm install` takes one URL per version — `docs/upgrading.md` had told operators to `helm upgrade nacre nacre/nacre` for the chart's whole life, from a chart repository that never existed. The migrations Job now applies the migrator of each module named in `modules` rather than globbing a private package family's names, which put the commercial scope into the open chart and applied tables for modules nobody loads. The four runbooks that are about operating the product came with it, into `docs/operations`, translated — and re-checked against the code on the way, which is the part worth keeping: they had drifted further than the chart had. Every `psql` in them ran as the application role against tables under FORCE row-level security, which raises rather than answers; the reindex rollback described a copy that marks itself failed when cancelled, where the fenced claim now abandons it silently; a Qdrant call went to a port the Compose file does not publish; and step 2a of the restore made an unsigned `HEAD` that a private bucket refuses. Nothing read them against the tree, because they lived where the tree was not. **A document re-sent during a reindex could be left off the new model.** Ingest replaces a document's points under fresh ids and writes only the live slot, and nothing cleared `reindexed_vector` — so a document the embedding pass had already marked, then re-sent, counted as migrated with points carrying no shadow vector, and the switch moved the layer onto a slot it was absent from. Lexical search still found it, which is what made it quiet. Found by checking the reindex rollback runbook against the code while translating it, and reproduced against a real PostgreSQL before a line was changed. Two halves, each named by its own case when removed: the upsert that rewrites the chunks clears the marker, and `markReindexed` marks only if the document's point ids are still the ones the vector went onto — the set of ids, not `content_hash`, because a requeue re-indexes identical content under new ids. **Skills are on the console.** A Skills screen holds the organization's skill and every reachable layer with its own, and a layer's skill also opens from the Layers screen. Both are one panel: the markers, the history, a file tree that keeps subfolders as folders, and Markdown rendered through `textContent`. A layer's skill is written by somebody with `admin` on one layer and read by the organization's administrator, which is the stored-script shape at its worst, so raw HTML stays text and only `http(s)` and `mailto` become links. Whether the caller may write is `versions` answering, not the role. A platform administrator gets the installation's level on the same screen, rather than on a second copy of the panel in the commercial console. **The screenshot pass now measures 390 as well as 1280**, and a fifth rule, `wordsWhole`, is why. The first layer table on that screen had four columns, and at a phone's width the description broke after every letter or two. Every rule passed, because every rule ran at 1280. The rule asks whether a run of letters breaks across lines in a table cell. It was measured before the fix: it also named the Access log, where `overflow-wrap: anywhere` on `.named` let the actor column shrink to one letter and printed `dana@exam` over `ple.com`. `break-word` wraps an over-long value just as well and does not count every character as a place to shrink to. Hyphens, colons and `@` are left alone, because a slug wrapping at its hyphen is correct; the first version flagged those, and that is how this was settled. **The consent screen has its Edit skill box, and `skill` is read in one file.** A token from the consent flow reaches REST as well as MCP, so `admin` on that screen would hand an MCP client renaming, deletion and grants. The box stores `skill` in a layer's ceiling instead (migration 0037), and `skill-ceiling.ts` is the only code that gives it a meaning: `may_write_layer_skill` resolves the *person*'s `admin` and asks that file whether the ceiling on the layer holds `admin` or `skill`. `resolve` never sees the value — `ceilingOf` strips it — so a `{skill}` connection resolves to nothing for every verb, which T29 asserts against renaming, deleting and granting with the person's own success beside each as the control. `lint:skill-ceiling` refuses a second reader anywhere in the server's sources, and refuses when the one reader stops reading or stops being asked. Each property was measured: a ceiling clause that always admits, `skill` handed to the resolver as `admin`, and the person left unasked each turned their case red. The box appears only where the person administers the layer, which the screen could not know: `GET /v1/layers` carries `permissions` now, what this caller may do on each layer. A screen that guesses offers what the server refuses. **Writing it found the layer catalog leaking past a narrowing, on both surfaces.** The search path says in its own words that a delegation must not be a way to learn which layers its person reaches, and T20 says a narrowed delegation gets nothing from another layer — while `GET /v1/layers` and MCP's `list_layers` listed it every layer its person reads, with names and document counts. MCP's catalog was worse: it built its resolve input by hand with no ceiling at all, so a `{write}` connection was listed everything — the fourth copy of a resolve input that `ceilingOf`'s own comment said a compile error would catch, and nothing caught. It goes through `contextFor` now, as an exported `layerCatalog` the suite can ask directly, and T40 holds both surfaces. **And the connections listing had never matched its contract.** The server sent the narrowing as `{ id, permissions? }` from the day a layer could carry a ceiling, under a contract saying bare ids, and never sent the `permissions` it documented — so the SDK's `map(String)` turned every narrowed connection into `[object Object]` and every ceiling into "none", and nothing displayed either, which is why nothing noticed. The contract says what is sent, the SDK reads it, a case pins the shape and failed on the old mapping, and the Connections screen says under each application what it may do — the only place a person can check afterwards what they approved. The same pass found `failed_count` filed under `Document` in the contract; it is the layer's. **The screenshot pass photographed neither screen before**, and the consent screen sits outside the nav, where the guard that reads the active nav item has nothing to read. It asserts the heading for such a screen instead. The first render showed what no rule measures: ticking any per-layer box hid "It may", and the group after it lost its gap, because `.choice + .field-group` holds only while the group directly follows a choice and a `hidden` one is still the previous sibling. The note beside it had already learned that and grown a second arm. A nested group carries its own margin now, since sibling margins collapse, so the gap no longer depends on what precedes it. **The administrative MCP writes, and every write is a proposal a person applies — 0.34.0.** Eighteen tools — people, groups, workspaces, layers, grants, skills, connections — and each one *proposes*: it resolves the names it was given, stores what it would do in `admin_proposals` (migration 0039) bound to the connection and its person, answers with the server's own sentence, and changes nothing. The change happens when the person presses **Apply**: in the change panel beside the conversation, through the app-only tool `apply_proposal` a host leaves out of what the model is offered, or on the console's Proposals screen, which answers a person's own session and nothing a connected application holds. Either way it is one `UPDATE` from `open` to `applying`, conditioned on the row being open, unexpired, the caller's, and its connection not revoked — so two presses apply it once and an expired one is the same refusal as one that never existed. T32 holds the whole of it against a real PostgreSQL, and four guards were each measured by removing them alone — the connection in the panel's claim, the expiry, the console refusing a delegation, and `propose` writing nothing. **And the first version let the model apply its own proposal**, which a full review found before it shipped and reproduced against the real server. The id travelled in `_meta` "and nowhere the model reads" — and in the access log, which this surface's own `query_audit` returns; and a legacy-era client, which is what the hosts rendering apps are today, was offered `apply_proposal` because nothing tells the server it will not honour `visibility: ["app"]`. So `issue_grant`, `query_audit`, `apply_proposal` granted access with nobody in front of it. The panel's Apply presents a **key** now, handed to the panel in `_meta` beside the id, stored only as a hash, and written nowhere a model reads — so the id being findable stops mattering. The case that held "the model is not given the id" asserted it of the result text alone, which is the narrow-projection defect this file names three times; the new case reads the id out of the log the way a model would and is refused three ways. The same review found four smaller things, each with a case now: revoking a connection hid its proposals but re-approving the application re-opened the same row and **revived** them, so revocation cancels them in its own transaction; the expiry sweep failed an apply still running across its expiry; a write refused at proposal time and a press on a proposal that was not there left **nothing in the log**, which is the half of an injection that shows; and a 36-character non-UUID id was a `500`. Asking the stand what the default skill reads like, as an agent would, found the search tool describing a layer with no description as `Engineering — (3 docs)`, and naming layers by the name when `layers` takes the slug. It names them by slug now, and the per-tenant leak checks beside it had to become case-insensitive — a negative looking for the capitalised name would have passed by not finding a spelling the description no longer uses. The specification offered an organization a switch to **apply directly** for clients that cannot render a panel. It was dropped rather than built: every way of making that switch safe ended at a person pressing Apply somewhere, and once the console held the button there was somewhere. A setting whose safe value is "never" is a setting somebody turns on. `registerMcpTools` is the seventh extension point, and its shape is the argument: a module's write is `propose` and `apply`, never one function, so a module cannot add a write that skips the person — and a proposal records its module, so a different module registering the same name later cannot be handed somebody else's proposal. The console grew a **Proposals** screen and no nav item for it. A permanent item was an empty screen nearly always, and the screenshot pass showed it as the tenth item wrapping the nav onto a second line; an item shown only while something waited wrapped it too, and so did a link in the masthead, by taking the room the nav had left. What is waiting is a line above every other screen now — "2 changes an agent proposed are waiting for you. Review them" — which is also the more honest place for something that expires in ten minutes. Moving the count onto every navigation is what broke the WebAuthn end-to-end run in CI, and it was a real defect: after signing out, the old session's hash handler asked `client()` for proposals, `client()` throws synchronously with no token, and a `.catch` on the promise cannot see a throw that happens before the promise exists. The sign-in screen got a page error. It asks only with a token now, inside a `try`, and the event listener is one module-level property rather than one more `addEventListener` per sign-in. **The administrative MCP sends mail, and only to people in the organization — 0.35.0.** `send_notification` proposes an email; alert rules — a skill version an agent wrote, one adding scripts, an administrative connection approved, a denial spike, documents failing — are kept by the organization and checked by the worker every minute with no agent connected. All four tools are proposals or reads, and exist only where a relay is configured. T35 is held three times rather than once, and each was measured alone by removing it: the tool resolves every address to an **active person in the caller's organization** and refuses before anything is stored, with one sentence for "elsewhere" and "nowhere" so the refusal is not an oracle; the apply resolves again, because somebody disabled in the ten minutes between is somebody the organization just decided not to reach; and the worker reads the address **at sending time** from the notification's own organization — so a row written straight into the outbox naming another tenant's administrator reaches nobody, which the case writes one to prove. What is stored is ids: there is no column an address could go in, which is the whole of the leak argument made structural rather than checked. A notification **carries no links**. It is somebody's prose from the installation's own address, which is a phishing message's exact shape on the one surface whose threat model is a planted instruction — so a scheme, `www.` or a dotted name with a path is refused, the only link is to the access log, and the message says an agent wrote it and who approved it. An application's registered name is the one string in an alert somebody outside chose, so it is broken where a mail client would make it a link. Thirty an hour per organization, at most once per message — a worker that dies mid-send leaves it `failed`, not resent — and `notification.sent` for every one whatever became of it, because the one that did not go is the one somebody needs to find. **The relay reached a second process, and a third gets only the fact of it.** The worker sends, so the chart gives it `NACRE_SMTP_URL`; the transport only decides whether to offer the tools, so it reads `NACRE_MAIL_FROM` — the half of the pair that is not a secret — and the relay's password stays out of a pod that never sends. The mail renderer keeps a line break inside a paragraph now, in both parts; nothing it sent before had one, and a list of layers run into one line reads as a sentence that lost its punctuation. **The administrative MCP's reads open panels — 0.36.0.** The access log, connections, effective access and a new `layer_status` each render beside the answer in a client that draws MCP Apps: rows an actor's press narrows, a revoke, a layer × permission matrix in the permission colours, and a layer's failures with whether each comes back by itself. A press is the same proposal the model would make, applied with the panel's key — a panel adds no path that skips the person. **The Layer panel had a Retry, and it was taken out rather than shipped.** It proposed `retry_document` and its apply went through the connection the panel holds — and the administrative connection's ceiling is `read` and `admin`, set at consent with a comment saying why: this surface changes no documents. So the button would have been refused on every press, and the live case that applied it was the first thing to say so. Widening the ceiling would have widened every administrative connection already approved, for one button; the panel says which failures will not recover instead, and the guide and the `layer-health` prompt stopped claiming a retry on a Layers screen that never had one. **And the default skill contradicted itself on the one write it cares most about.** Asked as an agent would ask it, against the stand: step 2 says to update a document that exists rather than add a second, step 4 says to derive the `external_id` from the subject — and a search hit carries a `doc_id` and no `external_id`, so an agent obeying both re-sends the stand's `on-call.md` as `engineering/on-call` and the layer has two. The guide now says where a hit's `external_id` comes from (`get_document`), and step 4 is for a new document. The plain-words and exact-term searches the skill asks for were worth keeping: the first ranked `onboarding.md` above `on-call.md` and the second put it first. **Access is changed from a panel now, and a module's listing can open one — 0.37.0.** `list_grants` opens a Grants panel: who has what on which scope, a Revoke on every row, and where the read was about a scope a form to give access there. Asked for in those words — "enterprise tools, for example document access" — and the answer was not an enterprise panel: the core already evaluates document grants and denies, so drawing them is the core's, and what a module adds is a read that *names* the core's panel and the writes its form may offer. The core checks every offer is a write on this surface before the panel sees it, so a module cannot offer `apply_proposal` or anything else that is not a proposal, and it ships no markup at all — no second renderer of somebody else's text. `acl-advanced`'s document listing is the first to use it. Rendering it found that a panel's `status()` searched every descendant, so a refusal written into the confirm area became "the" status line and the panel's own count went stale beside a fresh one — two numbers for one table. It reads its own line now, and Connections and Layer had the same latent defect. **The vector store's client pinned a vulnerable `undici`, and the newest one still does — 0.36.1.** `@qdrant/js-client-rest` depends on `undici` at exactly 7.29.0: two high advisories, one of them a certificate check a dropped connection can skip, on the connection that carries every vector. Found by the push message naming alerts on the default branch and `pnpm audit` naming the package, not by a suite — nothing here could fail on it. The workspace overrides the range below 7.29.1, so the images carry 7.30.0; a registry install of the core resolves the pin itself, and `docs/upgrading.md` says what to add there. **Where to connect is on a screen**, which was asked for in those words. The MCP endpoint and the REST base lived in `docs/quickstart.md` and in an operator's `.env`, so a person handed a login had to ask somebody for an address the server already knew. `GET /v1/endpoints` says it — built from `NACRE_CANONICAL_URL` and `NACRE_MCP_CANONICAL_URL`, not from the page's own origin, which is the answer only where everything shares one — and gives the administrative MCP only to somebody who administers, because its consent screen refuses everybody else. The Connections screen shows the three addresses with copy controls and example requests that run as written, and the dialog that shows a new service account key once says where to send it. **What that panel shows depends on who is looking — 0.37.1.** A member is shown the MCP endpoint and how a client takes it; the administrative MCP and the REST half are an administrator's. The REST half was the mistake: five requests and a contract link, every one of them needing a `nacre_sk_` key a member cannot mint, so a member arriving to connect an agent read an address they could not use under one they could. The deciding fact is `GET /v1/me`'s `administers`, the predicate the gated handlers call, read once for the screen; the server goes on telling anybody the REST base, because the address is not a secret and a member signed in to the `nacre` command uses it. The administrator's picture is byte for byte what it was, and `connect-member` is the member's — listing only their own connections, since that is what the server sends a member. Rendering it found a defect in the geometry rule rather than in the screen: the contents of a closed `
` keep their boxes in Chromium, which hides them with `content-visibility`, so the headroom rule measured collapsed examples six pixels above the table under them. It asks `checkVisibility()` now, and still named the one real defect — a copy control flush against the panel's heading — when that was put back. **And CI's database stopped being pulled anonymously.** The commercial modules' 0.10.16 release failed three times running on `toomanyrequests` from Docker Hub before a line of that repository ran, and a re-run cannot help: the limit is the runner's address, not ours. Every service container here whose image names no registry signs in with `DOCKER_USER` and `DOCKER_PAT` now — thirteen blocks in four workflows — and `check-workflows.mjs` asks it of every such block, in both repositories, in the same words. The first attempt was a registry mirror; a login is the smaller change, and the credentials were already there. **A connected application could approve itself a wider connection, and 0.31.1 closes it.** The consent endpoint refused an agent — "a service account cannot consent on behalf of anybody" — and checked nothing else, because a person's token was taken to mean a person in a session. A delegation is a person's token too, and REST accepts it: an application connected `{read}` could register a client of its own, `POST /v1/oauth/consent` with its own token and no ceiling, and exchange the code for a connection reaching everything its person reaches. Found while reading that handler to add the administrative MCP's consent, not by any case — the ceiling's suite asks what a token may do, and approving a consent was never on its list of things a token might do. T41 is that case now, over the real handler and a real database, and with the guard removed it answered `200` and a code. **A full security review swept every surface, and 0.38.0 is what it found.** Fifteen findings, each reproduced before it was fixed and measured red with the fix taken back out. Five were about credentials and authority. The idempotency cache held five more once-shown values the service account key's entry had never covered, so it refuses any body carrying a credential field now, whatever route produced it. A recovery link spent scrypt before it knew whether the token existed, on a route with no rate limit, so forged links could fill the gate every sign-in shares. A delegation narrowed to some layers could rename, delete, grant on and reindex the layers outside them, and an `org_admin`'s could administer the organization outright, because `administers()` read the ceiling and not the narrowing (T42–T44). An upload ticket kept the authority it was minted with past a revocation, and asks again on redemption now. Consent's Cancel redirected wherever the page said. Two were about tenants. `/metrics` read cross-tenant gauges outside `acrossOrganizations` and was silently empty on a split deployment. The provider listing named its organization only through row-level security, which `lint:tenant-scope` now refuses across 244 statements: a predicate counts only inside a `WHERE` or `ON`, because the first version was satisfied by `org_id` in a select list. One was an invariant 5 race. A worker pass could write points for a document deleted or re-sent under it, so the pass is fenced on its own claim and writes under the row lock that a delete now takes first. The rest were containment: the console refuses to be framed, the parser shares no network with the stores and bounds a fetch and its parses, the adapter takes the variables it reads rather than all of `.env`, every workflow declares its token, third-party actions are pinned, a skill proposal shows every file it would write, and decided proposals are pruned with the access log. **The Layers screen offered a reader four controls it would refuse three of — 0.38.1.** Rename, Model and Delete were drawn on every row the caller could see, so a member who only reads was offered the whole administrative column and got a refusal from each. Found by walking the console as a member to write its manual, which is the fifth defect this console has surfaced by being looked at rather than tested — and the screenshot pass could not have seen it, because its only Layers shot was taken as an `org_admin`, for whom every control is true. Each control asks the fact its route asks now: the workspace's `admin` for renaming and deleting, the layer's `admin` for a model, and nothing for a skill, which is visible exactly when the layer is. `layers-member` is the shot that was missing, and it throws on the first refused control rather than photographing it; the shipped view named `Rename` the moment it was put back. **Three more came off the same walk, and each is a rule now rather than an edit — 0.38.2.** People's two head buttons sat mid-row because `.view-head > div { flex: 1 }` was written for the heading block and also matched the button group, which is a `div` too. The Access log shortened an id only when it was the *whole* value, so a search's `returned_docs` printed as a wall of full uuids and a grant's `scope: layer:` carried one — invisible to the screenshot pass because its search and grant targets were shapes no server writes, which is the fixture-agrees-with-code defect once more. And "No second factor" centred its words and left its buttons at the box's edge, since `text-align` does not move flex items — in the state every account starts in, which no shot photographed. `headActionsAtEdge`, `idsShortened` and `emptyStateCentred` are in `layout-rules.mjs`, so the commercial console is held to them too; each was run before its fix and named exactly the shots it should, and nothing else. A fourth came from reading the dialogs' accessibility tree for the manual's selectors, since those are the names a screen reader says: `picker` sits in a `div.field` beside a ``, so its select had no name and Issue a grant announced "combo box" twice, and the two boxes that hold a password or a key once had none either. `picker` names its own controls now and `controlsNamed` asks every visible control. Its first run also showed that no shot had ever opened Issue a grant or a service account's key — a rule only sees the screens somebody photographed, so both are shots now. `idsShortened` then found the same defect on the commercial Backups screen, whose "Started by" printed `user:`; that is fixed on that side of the boundary, which is what putting the rule in the shared file is for. **And Search drew a Delete on every hit — 0.38.3.** Found by searching as a member for the manual, the morning after the Layers screen was fixed for the same thing: a reader offered a destructive action the server refuses. Deleting a document is `write` on its layer, so the view asks `GET /v1/layers` which layers the caller may write and draws the button there only; `search-member` throws on the first one it finds, and named two when the shipped view was put back. Two screens with one rule between them and nothing that knew there were two, which this file has a paragraph about. **A restore that kept the vector store left a collection that silently disagreed with the database — 0.39.0.** Found by restoring a backup on a running stack, which nobody had done: the archive came back into Postgres, and a document it held was absent from every search. Two disagreements, both quiet. A document deleted after the archive was taken returns as a live row while its points stay flagged `deleted: true`, so the pre-filter never admits them. A document added afterwards leaves points whose chunk rows the restore removed, and hydration drops a hit with no row — correctly, and invisibly: a place in `top_k` handed to nobody, for as long as the point exists. `rebuild-collection` was the remedy and refused it, by design: it is a create, and over a live collection it deletes every vector. That refusal is right for a collection that is merely lost and wrong for one that survived a restore, so `--replace` says which, and is never a default. The rebuild moved into the library as `rebuildOrganizationIndex` because a restore on a running installation is a second caller — the commercial backup surface — and two answers about what an organization's collection is made of would be two answers about the thing every search depends on. `rebuild-live.test.ts` builds the state a restore leaves against a real PostgreSQL and Qdrant; dropping the replace branch, or the requeue, each names its case. **The MCP transport kept every request it served, and every search read the whole organization — 0.39.1.** Neither is visible to a suite, because both are about what happens on the ten-thousandth request; both were found by putting the running stack under load with k6 and reading what each container was doing. The transport grew by about 80 KB a request and never gave it back. Streamable HTTP builds a server per request, `catalog()` writes each tool's schema as a fresh literal, and `fromJsonSchema` compiles its argument with Ajv on a validator the SDK holds at module level — and Ajv keeps every schema it ever compiled. A heap snapshot diff said so in one line: ten `SchemaEnv` per request, one per tool. Capped at 200 MB the transport died with `JavaScript heap out of memory` after some 3,600 searches, dropping three thousand in flight; it also spent most of a search's CPU on code generation, so MCP ran a search at four times REST's cost. `schemas.ts` compiles a schema once per process, keyed by content because the schemas are static, and the same 200 MB transport then served 14,300 searches flat, at twice the throughput. `schemas.test.ts` counts real compiles across twenty-five builds for different callers, and refuses a second file calling `fromJsonSchema` — both restorations measured red. And every search's last step — joining the hits back to their rows by `chunks.point_id` — had no index on that column since 0001. The planner walked layers, then every document, then each document's chunks, to keep ten: 36 ms a search on 27,000 chunks and linear in the corpus, with Postgres the bottleneck at 152% CPU. Migration 0042 adds the index and the same load went from 65 to 125 searches a second. `hydrate-index-live.test.ts` explains the query text the search path runs, exported as `HYDRATE_SQL` so the test holds no copy, against a real PostgreSQL with enough rows that a walk is a plan worth refusing. **And the index was not enough, which CI said and a fresh database proved.** The case's first run went red with the index present, and so did the pull request's: the planner started from `layers`, hashed every document of the organization and probed each one's chunks — choosing the walk over an index it had. The reason is statistics, not the index. `layers` had never been analyzed, which is the ordinary state of a table of a few rows, because autoanalyze waits for fifty changed rows; a never-analyzed table misleads the estimate of every join it is in, and the planner reckoned the walk would touch seven documents. So on a real installation with a handful of layers, the index alone could leave every search walking the organization. The query says where to start now: the chunks are looked up first, by id, in a `MATERIALIZED` CTE the planner may not reorder, and only those rows are joined to their documents and layers by key. The test leaves `layers` unanalyzed on purpose, because that is the state that failed. Measured on fresh databases: the flat join failed three runs of three, the CTE passed five of five, and dropping the index fails it. Its first version also missed a bitmap plan — the index is named on the `Bitmap Index Scan`, which carries no relation — so it asks every node. The same pass found the nightly flake hunt discarding the evidence. The run of 2026-10-09 failed one pass without naming a case and printed nothing of it — the output was thrown away and the pass's own line read `clean` — so the red was a number nobody could act on. It prints the failed pass's tail now, and ten local passes found nothing to print. **A person could be linked to an identity provider only with SQL — 0.40.0.** `users.external_id` is the subject the commercial SSO module signs somebody in by, and `groups.external_id` is what its SCIM sync addresses a group by. Both columns have been in the schema since 0001 and nothing in the product wrote either: no field on any endpoint, no flag, no box. The enterprise suite was green throughout because its fixtures wrote the columns with `INSERT`, which is the fixture-agrees-with-code shape arriving across the boundary — the module's half was right, and the half a customer needed to reach it did not exist. Found by driving SSO and SCIM end to end against a real identity provider stand-in with nothing but the console and the API, which stopped at the first sign-in. `POST`/`PATCH /v1/users` and `POST /v1/groups` take `external_id` now, and `PATCH /v1/groups/{id}` exists for it alone; the CLI and the People screen carry it. One id names one row per organization — `users` has had that constraint since 0001, `groups` gets it from 0043, because a SCIM push finds a group by the id and replaces its people, so two groups sharing one would hand one team's grants to whichever a lookup found first. A second claim is a `409` naming the kind of id, and the unique violation is read by constraint name rather than assumed, because the email constraint on `users` raises the same SQLSTATE. A platform administrator's subject goes through `onTargetUser` like every other write to that account, since a subject is a way in: an `org_admin` able to set one on the installation's administrator could sign in as them with a token from a provider they configured. **A caller could take the API away from everybody else, and registration was bounded by nothing — 0.41.0.** Only search and ingest were counted, per organization, so a client in a loop over a listing or over `tools/list` was limited by nothing at all; and `/oauth/register` wrote a row per post with no limit and no sweep, under a migration comment saying "plus the rate limiter" about a limiter that never looked at it. Every authenticated request now counts against the **principal** on one budget across REST and MCP, generous by design — ten a second sustained stops a loop and nothing else — and the window slides, so there is no boundary to burst across and none after which a refused caller is suddenly free. A refused request is given back, so knocking while waiting does not push the wait out, `Retry-After` is the time until the next request fits, and the refusal is audited once per window rather than once per request, or the limit would become the way the log is flooded. Registration is limited per client address, and the worker deletes a client nobody approved after a week unseen — "seen" being the authorization endpoint asked for it with a redirect it registered, after that check, so only a request that could reach a consent screen keeps one alive. **An organization's total is not counted by the core, and that is the decision rather than the gap.** With one organization a total is a ceiling on the installation, which can only refuse somebody the hardware could have served, and the per-caller budget already stops the loop it would exist for. Several organizations on one installation need one each, so the core declares the eighth extension point, `registerRateLimits`, and counts `org_requests` only where a source names a limit — with the numbers a module sets and the windows the installation keeps. Both surfaces admit a request through one `admitRequest`, the caller first, and its first test of the order could not fail: the give-back makes the counts the same either way round, so what the order decides is only which limit the refusal *names*. The case asks that now — a looping caller is told it is its own loop even when its organization is full too — and swapping the two steps turns it red. **Client ID Metadata Documents are in, beside registration**, and the reason they were declined is the reason they could be built. The decline was an outbound fetch to an address an unauthenticated caller chose; `egressFetch` answered exactly that for the embedder and the parser in 0.26.0 — addresses judged at connect time, no redirects — and nobody went back to the row that said no. The fetch goes through it, bounded and cached, spends the registration budget, and a stale document whose refresh fails is a refusal rather than the old copy, because a client that dropped a redirect URI must stop receiving codes there. Three places ask whether a redirect is allowed — authorize and both consent decisions — and they ask through one function, since a metadata client may use any loopback port and a registration may not. The consent screen names such a client by the domain that served its document, and says in words that a loopback client is only as good as the program presenting its name, which is the one weakness the specification itself names. **The skills had gone stale, and nothing reads a skill against the tree.** `.claude/skills/` is what an agent loads before it touches the model, and five of the seven described a product several releases old: `acl_tags` as a cache to keep in step (removed in 0016), `tools/list` cached per user for five minutes, client registration behind two variables that were deleted, a server with no `initialize`, four extension points of seven, and a commercial list carrying collection isolation and the audit log's export, both of which are core. The same pass found the tombstone gauge documented as the propagation gauge it replaced, an OpenTelemetry trace nothing emits, a second paragraph offering `NACRE_2FA_KEY` as a file after the first said there is none, and `docs/config.md` saying there is no registration endpoint beside the API's `/oauth/register`. A skill is a document with the most trusting reader there is, so correcting the code is not done until `grep` has been through `.claude/skills` too. **Dependabot's pull requests could not run CI at all — 0.41.1.** Every job with a service container failed before its first step with `The template is not valid … Unexpected value ''`, because those containers sign in to Docker Hub with `secrets.DOCKER_USER` and `secrets.DOCKER_PAT`, and a run Dependabot triggers is given *Dependabot* secrets, not Actions secrets. Main stayed green, the bumps sat red, and two of them were security fixes — nodemailer and undici, beside a high advisory in zod under the MCP SDK that `pnpm audit` named the same day. They landed through an ordinary branch, and **that is the procedure rather than a workaround**: the Dependabot copies of those two secrets are deliberately not configured, so a Dependabot pull request is a report and never a merge. Whoever works in these repositories checks for open ones first, takes the bump into their own branch — merging Dependabot's branch keeps its lockfile and closes the pull request when it lands — runs the checks there, and closes it with a line naming the pull request that carried it. Every repository, not this one alone: the same Docker Hub login is in `nacre-enterprise`'s workflows too. Dependencies move to their newest release **at least two weeks old** (`pnpm update --latest --config.minimum-release-age=20160`), because a release a few days old is the window a hijacked package is live in. A peer the tooling needs is declared rather than left to auto-install: pnpm held vitest's vite at a vulnerable 8.2.0 through both an update and an override until it was named. - **English everywhere** — code, comments, commits, branches, issues, PRs, docs. - Conventional Commits: `feat:`, `fix:`, `docs:`, `chore:`. - Squash merge, linear history. One PR, one topic. - **CLA, not a DCO.** [CLA.md](./CLA.md), signed by a pull request adding you to `.github/cla/signatures.json`. Enforced by the `cla` job, which compares commit author and committer emails against that list — read from the *base* branch, so a pull request cannot sign itself. List every address you commit from. - Changes under `packages/core/authz` need **two maintainer approvals** and tests. - **Say when a change needs a human to do something outside this repository**, in the pull request body and when reporting the work — not once it has failed. The list is short and every entry on it fails *after* a merge, on the commit that is already the release: configuring trusted publishing for a new npm package, a DNS record, a registry entry, an npm or GitHub setting, a credential that has to exist before a workflow can use it. A change that is green in CI and inert in production because nobody was told to press something is the worst version of "done". `@nacre.work/cli` is the instance that produced this rule. - **Adding a publishable package is not only code.** Five things, and the fifth is the one above: see [docs/releasing.md](./docs/releasing.md), which `lint:publish` holds against the manifests so it cannot be skipped. The count is held too — it was written in both files and moved in one. - Don't optimize `authz/reference.ts` when it exists. Its whole value is being obviously correct so the property test can catch drift in the fast path. ## Skills `.claude/skills/` carries the checklists for work that touches the model: `authz-change`, `mcp-tool`, `api-endpoint`, `db-migration`, `audit-event`, `config-var`, `open-core-boundary`. They load themselves when relevant.