Nacre

Releases

What shipped, and what it cost to find out.

Each note says what changed and, where it is the more useful half, what was wrong before — including the defects that were green in every suite and obvious a minute after starting the processes. Versions are the core’s (nacre-work/nacre); the commercial modules version separately.

0.3.0 is released: the packages are on npm (@nacre.work/core, -api, -mcp, -sdk), the images on ghcr, and the Compose stack serves the admin UI on its own origin the moment it is up. A lost vector collection rebuilds from Postgres with one command. Releases are cut by CI from a version bump — no human step between a merge and the registry.

0.4.0 is released: a PDF can be uploaded. The file part has to declare application/pdf and the bytes have to start with the PDF magic — either alone is refused, because a declared type the bytes contradict is a disagreement and not a hint. Binary needs object storage configured, and the refusal for a deployment without it arrives on the request rather than as a failed job an hour later. Extraction is a pinned, pure-Python dependency in the sidecar, kept current for its denial-of-service fixes: it is the code that reads whatever anyone uploads. Anything binary that is not a PDF is still refused at the edge.

0.5.0 is released: a team can be onboarded. Users, groups and membership are in the API and on a screen — before this the schema offered all three and the only route to two of them was an INSERT by hand. Grant the group rather than the person and joining the team becomes a membership change instead of a grant nobody remembers to revoke. A password is generated and shown once, never accepted: one an administrator chose is one they know. A layer can be deleted, taking its documents with it. And the readiness probe stopped reporting ready on a database the migrations have not reached — it had for four releases, and under an orchestrator that is the difference between a rollout that halts and one that replaces working pods with broken ones.

0.5.1 is released: fixes, every one of them found by somebody using the product rather than reading it. The MCP transport was not reachable by a real client — it demanded a header the specification asks for on three methods and it demanded it on all of them, so listing the tools was refused and there is no other way for a client to find out what a server offers. It required the mirrored headers and never compared them with the body, which is the whole reason they exist. And it did not validate Origin, which is what stops a page in somebody's browser reaching a server on their network. Issuing a grant checked that the scope existed and not the principal, so a grant could name an id that was nobody: a row that permits nothing and never will.

0.5.2 is released: the published images carry arm64 alongside amd64. They were built for one architecture, so anything that pulled one on arm64 ran it emulated — a Helm deployment, an arm64 node, a plain docker pull — which is a slow container rather than an error, and so went unreported. Compose was never affected: it builds from the Dockerfiles locally, so a laptop has always got its own architecture. CI now starts the arm64 image on every pull request rather than only building it, and the release refuses to finish if an architecture is missing from what it pushed. Writing the Apple Silicon instructions found the other half: a model endpoint with a path had the path thrown away, so every hosted OpenAI-compatible API answered 404 while the bundled one worked. The embedder is the one piece with no arm64 build anywhere, so on a Mac it runs on the host, against Metal — faster than the emulated alternative would have been.

0.5.3 is released: an MCP client can get a token without one being pasted. The API is an authorization server now — discovery, client registration and the authorization code flow with PKCE — and the discovery document names it. The decision that makes it ours rather than generic: the token acts as a service account, never as the person who approved it. A consent screen that mints the approver's own authority collapses "what may this agent read" into "what may you read", which is the distinction the whole permission model exists for. The screen asks which agent; revoking that agent stops the client and touches nobody else.

0.5.5 is released: an MCP client can actually connect. The handshake counter-offered the newest revision this server speaks to any client proposing something else — and the newest revision any shipping client knows was missing from the list, so every one of them was handed a version it had never heard of and gave up. A client arriving on the handshake is by definition an older generation and cannot fall forward, so what it is offered has to be something it knows. Every path the MCP transport does not route answered a JSON-RPC envelope, so a client looking for an OAuth endpoint there read it as an error response and reported a schema mismatch instead of a problem. And the consent redirect dropped the route out of the URL, landing the browser on a page with no way to approve anything. All of it was invisible to a green test suite and obvious within a minute of driving a real client. The admin console is a published image now and the Helm chart deploys it — the chart had a plausible reason for not doing so, and the real reason was that nothing built it.

The same release made a connected application forgettable, from a screen. Ending a connection deletes the refresh token, so the client stops at its next renewal; the access token already issued is verified against a key and outlives the revocation by at most its own lifetime, which the response says rather than pretending otherwise. A member sees their own connections, an administrator the organization's. (This note used to say 0.5.4. There is no 0.5.4 — the number was skipped and the work landed here, and a version nobody can install is not one to announce.)

0.5.6 is released: three addresses that cannot be localhost, one of which a fix had already removed. The Compose file set no MCP canonical URL and explained at length that the absence was the fix — unset, the transport builds its identifier from the address each client actually reached — while the example environment file seeded one, and that file is read by every service. So every deployment that started by copying it had the derivation switched off and named localhost to everybody: a refusal before a token is sent, which reads as a broken server rather than a setting. A check now renders the stack the way an operator copies it and fails if the value comes back.

0.5.7 is released: one address instead of three. The Compose stack serves the console, the API, the authorization server and MCP on a single origin, so one value has to be right instead of three. Two of the three could never be derived: an issuer is a value, and a consent redirect knows the host a browser used but not the port the console is published on. Behind one origin they are the same string. This is what the Helm chart's ingress always did — the Compose stack was the odd one out — and the separate ports stay published. Building it found the defect it would have shipped with: the proxy's default host variable strips the port, and forwarding it made the MCP transport advertise an identifier without one, which a client refuses on. That is the exact failure a single origin exists to prevent.

0.5.8 is released: a scanned PDF is refused instead of reported indexed. A scan has no text layer, so extraction returned an empty string, and an empty string chunked to nothing, embedded nothing and was stored as a document in indexed — accepted, searchable by nobody, and visible only as a chunk count of zero that nothing read. The extractor is pdf-inspector now, which can say whether a text layer is there; the refusal names what happened and that this build does no OCR, and a file that simply declares no pages gets a different one, because telling an operator a broken file is a scan sends them to buy OCR. The worker passes the reason through rather than the status code it used to report alone. Nothing was given up in the swap and that was checked rather than assumed: identical text on a text PDF, and the same refusal on garbage and on an encrypted document.

0.6.0 is released: an application can act as the person who approved it. OAuth exists so a person can let an application act for them, and until this release the consent screen could only offer a service account — and both listing and minting one are org_admin, so anybody else who followed an MCP client's link reached a screen they could not use. Approving as yourself is now the default: the application reaches exactly what you reach, recomputed from your grants on every request rather than baked into a token, and you can restrict it to chosen layers as you approve. Nothing is granted to the application itself, which is what keeps this from adding a way for the permission model to be wrong. And disabled gains a second meaning on this one path: disabling a person suspends every application they had connected, on the next request, without touching a grant — so re-enabling them restores everything rather than asking anybody to reconnect.

0.7.0 is released: a connected application can be restricted to reading. 0.6.0 let a person hand an application their own reach; this adds the dimension they actually want over it. The consent screen now asks what it may do as well as where — search and read, or also add and change — with only reading ticked, because a person connecting a search client means "let it search", and a screen whose default is everything is a screen nobody reads. A set rather than a level: write does not imply read anywhere in this model, so an ingest client that cannot search what it wrote is something you can express rather than an accident. Administration is deliberately absent from that screen — the MCP surface has no administrative tool, so the box would have done nothing where the person is looking and a great deal through the API, where they are not. And initialize now carries the MCP specification's instructions field, which we had been sending nothing in: a client's model is told that a "not found" here is deliberate, that an empty search result is an answer rather than an error, and that its connection may have been narrowed by the person who approved it.

0.8.0 is released: your embeddings can come from somebody else's GPU, and nothing about that is a default. A laptop has no good embedder — bge-m3 under emulation blows the worker's budget — so a small sidecar now speaks to hosted vendors: point an embedding provider at it and route a model. There is no default vendor, no default endpoint and no fallback; an unrouted model is refused by name, and the service is absent from the airgapped profile rather than switched off in it, because a container that is not there cannot connect to anything. The trade is stated in those words in the configuration reference: the text of your documents leaves your installation. Adding a second model no longer needs psql either — embedding providers have an API and a screen, which the schema has allowed since the first migration and no route reached. And a document that failed says why: the reason is stored and shown where somebody looks, instead of a status a query had to notice.

0.10.0 is released: 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 with two word lists, so the product minted credentials at two strengths depending on which door they came through — and the weaker one, 35.3 bits, was the door an administrator onboards a colleague through. One list now, and the entropy is derived from it rather than written in a comment that can go stale. Creating an organization became one function in the core, which found a second defect worth naming: an installation that changed its default embedding model gave every organization created afterwards a collection slot no layer would ever write to, so every document failed forever while the API answered queued. (0.9.0 has no release. Its version was cut and the publish failed; the work is in 0.10.0, and a tag nobody can install is not something to announce.)

0.11.0 is released: the embedding adapter has an image you can pull. It shipped in 0.8.0 as a service CI built on both architectures and no release step published — so docker compose could build it from source and a Kubernetes deployment could not have it at all. That is this project's most repeated defect, a thing that has to be true in several places with nothing that knows how many, and it got the usual answer: a check that every Dockerfile is published and every published image is held to carrying both architectures.

0.12.0 is released: three documents were described at an address that answers 401. The API contract declares one base, /v1, and the two .well-known documents and /metrics are served at the root — with no override, a client generated from that contract asked for /v1/.well-known/jwks.json, which sits under the authenticated surface. Not a 404: the address demands a credential, and a key document answering 401 to a client that has none yet is the one thing it must never do. The protected-resource document is read because of a 401, so reaching it cannot require the credential the client is working out how to get. Nothing about the server changed, and there is nothing to do on upgrade unless you generate a client from the contract — regenerate it. Found by a test that asks the running server rather than another description of it: every operation has to need a credential exactly where the contract says, and the open documents have to answer at the address it gives.

0.12.1 is released: a 401 from an embedding endpoint now says why. Pointing a provider straight at a hosted API cannot work — the request carries no Authorization header and there is nowhere to put one, because the providers table has no column for a credential and deliberately never will: a vendor key there would reach every database dump. That is what the embedding adapter is for. What an operator saw instead was answered 401 against a URL that was correct. Found by somebody reading the documentation, failing to set up a hosted API, and asking — and the paragraph they were on said anything speaking OpenAI's contract works by pointing at it directly, which is true only where no credential is wanted, with OpenAI named in the table just above. The message, that paragraph and the Apple Silicon guide are all corrected. Nothing else changed.

0.13.0 is released: the embedding adapter reranks, and the stack starts with one command on every platform. A deployment with no GPU had nowhere to point a reranker — the core speaks Text Embeddings Inference's /rerank and no hosted vendor answers that shape — so the adapter answers it, which means the core needed no code at all: point the reranker at the adapter instead of at a TEI container and nothing else changes. Cloudflare, Cohere, Jina and Voyage; OpenAI, Anthropic and Google publish no reranking API, and the refusal for an unknown vendor says that rather than only listing the four, because a bare list reads as "yours was forgotten". A route may also name the vendor's own spelling of a model — bge-m3=cloudflare:@cf/baai/bge-m3 — which is what lets an installation already indexed against those weights move onto a vendor's copy of them without a reindex: a renamed model is a different vector slot, so the alternative was a collection replaced and every point copied to move vectors that did not need to move. The arm64 overlay is gone, folded into the one Compose file, and that is the only thing this release asks of an operator — if your .env names it in COMPOSE_FILE, remove that line before upgrading.

0.14.0 is released: disabling somebody is reversible on the wire, not just in the database. Disabling a person suspends the applications they connected and deliberately does not spend their refresh tokens, so re-enabling them is a restoration rather than a reconnection — and the refusal in between arrived as invalid_grant, which RFC 6749 defines as this grant is dead. Every conforming client therefore threw away the token the server had gone out of its way to keep, and the only route back was the consent screen. That case answers 503 with Retry-After now; every other refusal is unchanged. Reported by somebody who disabled a colleague, re-enabled them, and watched the agent never come back — and found by driving the whole sequence against a running server, where every other step was already correct. The test that should have caught it asserted the half of the property that lives in the table and said nothing about the half that lives in the protocol. The connections screen went with it: "Acts as" said the person who approved it on every row, which on an administrator's list withheld the one fact the column exists for, so it names the person now and says outright when that person is suspended.

0.14.1 is released: a refusal says why, in the log. A hosted vendor rate-limiting you reached an operator as the embedding endpoint at http://embedding-adapter:8091/embeddings answered 502 — which names the one process in the chain that did not decide anything. 502 is the adapter's word for somebody else's service failed, and it knew which vendor and what it said; that was in a response body both ends discarded. The message carries it now (… answered 502: cloudflare answered 429) and the adapter logs the same line, which it had never done at all — its entire log was the sentence it printed at startup, so a container refusing every request looked healthy. Neither ever carries the vendor's own response body: a vendor's error can quote the input it rejected, and the input is document text. Reported by somebody whose documents indexed and whose searches then stopped, with nothing anywhere saying why. Authentication refusals go the same way — the 401 on the wire stays silent deliberately, because one that explained itself would be a probing oracle, and the reason goes to the log with the request id instead.

0.14.2 is released: a vendor's 401 names the variable that holds the credential. The release above made a vendor's refusal travel, and the first thing it surfaced on a real installation was cloudflare answered 401 — accurate, and one step short. A 401 from a vendor means the credential the adapter holds was rejected, which is the opposite of the 401 an endpoint you pointed at directly gives: that one means it wants a credential and Nacre sends none. The two read alike and point opposite ways, so the message now says which and names the pair of variables that could hold it. The pair is carried from the table entry that resolved the credential rather than looked up by vendor name, because cloudflare is in both the embedding and the reranking tables under different variables — naming the wrong one sends you somewhere with nothing in it. Only 401 and 403: a 429 is a quota and a 5xx is the vendor's own outage, and a paragraph about credentials on either is noise on a failure nobody at your end can fix.

0.15.0 is released: search carries the lexical branch it had always been described as carrying, and there is a nacre command. The architecture document specified “dense vector plus sparse BM25, fused with Reciprocal Rank Fusion” from before there was a server; every collection was created with a bm25 slot, the query builder accepted a sparse branch, and the permission suite passed one through it — and nothing ever produced a sparse vector, so the slot was empty on every point and every query was dense-only for the whole 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 fusion absorbs a prefetch that returns nothing without a trace. What it cost is the half of retrieval a dense index is worst at and an agent asks for most — error codes, contract numbers, variable names, surnames. Term frequency is written at ingest and IDF is the index's, because how rare a word is depends on every other point including the ones not written yet. No stopword list, since IDF already scores a ubiquitous term near zero; no stemmer, because it needs language detection to be safe and the terms it must not touch are exactly the ones dense retrieval cannot reach. An existing collection has the slot and no data in it, so the lexical half sees documents indexed from this version on — nothing is wrong without acting on that, and one command backfills the rest.

And a command line client. npx @nacre.work/cli signs in, creates a layer, walks a directory into it, searches and scores a layer's reference queries. The 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, which is why “under thirty minutes to a first search” had never been measurable. --watch keeps a directory indexed and never deletes, because a file disappearing is indistinguishable from the first half of how every editor saves. Three defects came out of running it and none was visible to a green suite: the password prompt printed the password, an ingest where every document failed exited 0, and search returned an empty document id through the SDK — the client read one field name where the contract says another, and its own test fixture agreed with the client rather than with any server.

0.16.0 is released: the command line administers an organization. Creating a colleague, a group or a service account had a REST endpoint and no client, so the documented way to onboard somebody was hand-assembled JSON with a token good for an hour. nacre users, nacre groups, nacre service-accounts and nacre audit close that over the same SDK, so there is no second idea of what the API is. Every published package arrives with a page describing it, and a check holds that — a package on npm whose registry page is a name and a version is one nobody can evaluate without cloning it.

0.17.0 is released: a platform administrator is not administered from inside an organization, and there is a demo you can run in one command. POST /v1/users had always refused to issue the installation-wide role, correctly and for a stated reason: that surface is scoped to one organization and the role spans all of them. Nothing enforced the argument in the other direction. An organization administrator could reach a platform administrator whose account happened to live in their organization by four different verbs — demote, disable, delete, and reset the password. The last is not a demotion: the endpoint returns the plaintext, so it was a takeover of the account that administers the whole 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 — so the repair is one helper every write to somebody else's row goes through, reading the target's role in the same transaction as the write, and a check that refuses a write that does not.

And a demo that needs nothing configured. docker compose --profile demo up seeds a company: three layers, three people, and one query with three different answers. Ask “what is the contract number for Northwind” as the administrator and it comes back; as the engineer or the contractor it returns nothing at all — not a shorter list, nothing, because the permission filter runs inside the index traversal and their search never reached the document. No embedding endpoint of your own and no configuration; the passwords are generated and printed, since nothing here accepts one somebody chose. Four defects came out of running it that were green in every suite, including a seeded stack whose logins nobody could recover after a restart.

0.17.1 is released: the front door stops overwriting the client’s scheme. Every proxying location in the nacre-web image forwarded X-Forwarded-Proto $scheme, and $scheme is the scheme of the connection into that container — which is plaintext behind an ingress controller, or any TLS-terminating proxy on a host. So the console’s nginx replaced the outer proxy’s correct https with http, and the MCP transport, which derives its RFC 9728 resource identifier from the request, advertised a plaintext URL for its own OAuth discovery document while the document at that URL correctly said https. One installation disagreeing with itself about its own scheme, and a client sent somewhere some clients refuse outright. Found by reading one header on a running deployment, which is where this class of defect keeps being found. Take the nacre-web image; nothing else changed, and no migration or new variable comes with it.

0.17.2 is released: a document over 22 KB indexes, and the ones that failed need re-ingesting. Both embedding clients sent a document’s whole chunk list as one request, and an endpoint does not split a batch that is too large — it refuses it. Text Embeddings Inference, which every Compose profile here starts, answers 413 above --max-client-batch-size, which defaults to 32. A chunk is 800 characters, so anything past roughly 22 KB produced more than 32 chunks and failed permanently, because nothing retries failed. Nothing looked wrong, which is the part worth recognising: the layer went on answering searches out of the documents that had indexed, so retrieval was quietly missing the longest documents 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 all read chunks: 2 and chunks: 3 — which reads as bad luck rather than as a threshold. Take the nacre image, then re-ingest anything that failed; that is the part no upgrade does for you.

0.17.3 is released: a document’s failure stops naming your infrastructure. GET /v1/documents/{id} returned the worker’s stored error verbatim, and so did get_document over MCP — which resolves read, so its caller can be a third party acting through a delegation somebody approved. That column holds whatever went wrong, written for an operator reading a log: the embedding endpoint’s address, the parser’s, whatever a sidecar put in its message. /v1/jobs/{id} had taken those out since 0.17.2; this path had not.

And the redaction held for the example, not for the deployment. It needed a hostname with a dot in it, and every service name this product ships with is a single label — embedder, qdrant, parser, minio — so it covered the example in its own documentation and missed every real configuration. The way one survived is worth recognising: the URL was removed, and then the HTTP client appended its cause, leaving getaddrinfo ENOTFOUND embedder at the end of an otherwise clean sentence. IPv6 was not redacted in any form. A host is recognised by where it can appear now — after a scheme, in brackets, as name:port, after a DNS or socket error code — rather than by what it looks like, because a token’s shape is shared with everything a person wrote. The cost is stated rather than left to be found: a filename is redacted along with the hosts, since contract.pdf and example.com are the same shape and of the two ways to be wrong only one of them leaks. Take the nacre image; no migration, no new variable, and nothing retroactive.

0.17.4 is released: a browser can reach this installation, if you name its origin. NACRE_MCP_ALLOWED_ORIGINS made the MCP transport stop refusing a browser and never made it answer one — there was no preflight handler and no Access-Control-Allow-Origin, so the browser discarded a reply it had been allowed to receive. Setting it did nothing anybody could observe. NACRE_API_ALLOWED_ORIGINS is the other half, because a browser client reads the 401 from the transport and then registers and exchanges its authorization code on the API: admitting it on one surface and not the other is a walk that stops one step after it starts.

Both lists are empty by default, which is what every existing deployment has, and an empty list emits no header and refuses a preflight exactly as before. * is refused at startup on either — nothing here treats it as a wildcard, so it would be a list matching nothing while reading as one that opened the surface to everybody.

0.17.5 is released: a browser MCP client can read this installation’s metadata. Access-Control-Allow-Headers was written out by hand on each surface, and the two disagreed: the transport’s carried mcp-protocol-version and the API’s did not — while 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 was sent.

Nothing broke, which is why it needed finding. 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 browser console and a flow that worked anyway. A client that does not retry gets no metadata at all. curl sees none of it, because curl sends no preflight — both documents answer 200. Found by driving a deployed page in a real browser and reading the failed requests’ headers. Take the nacre image; a deployment with both lists empty is unaffected either way.

0.18.0 is released: three ways to hold an account, and two of them are optional. A second factor arrives as TOTP — the six digits an authenticator app shows — with ten recovery codes minted at enrolment and printed once, because somebody who has lost their phone cannot ask for them afterwards. The arithmetic is held against RFC 6238’s own test vectors rather than against itself: a generator tested on its own output agrees with itself and with no authenticator anybody owns. A code is single-use, the brute-force bound lives in Postgres rather than in 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, so an administrator resets a password and deliberately cannot touch a second factor.

Password recovery, and changing your own password. The first needs a relay: NACRE_SMTP_URL and NACRE_MAIL_FROM, both or neither, and without them GET /v1/auth/methods reports password_reset: false so the console leaves the link off its sign-in screen rather than showing one that answers 404. Every outcome is 204 — no account, an address in two organizations, a disabled account, a relay that refused — because anything else turns the one endpoint needing no credential into the account-enumeration oracle sign-in is careful not to be. The link is built from NACRE_CANONICAL_URL and never from a Host header, since a recovery link built from a request header points wherever the requester said. The second needs nothing configured and is the one recovery path every installation has: it takes the current password and not just a session, because changing the password is the first thing somebody holding a stolen session does.

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. Two migrations, 0029 and 0030; both create tables and rewrite nothing, so they apply to a live database in the ordinary way.

0.19.0 is released: a second factor you cannot be phished out of. WebAuthn — a security key, or the fingerprint reader your laptop already has — joins TOTP, and it needs nothing configured. The relying party is NACRE_CANONICAL_URL’s hostname and an assertion is checked against that origin plus whatever NACRE_API_ALLOWED_ORIGINS already admits, so a NACRE_WEBAUTHN_* variable would have been a second answer to a question the deployment has answered twice. The origin is compared for equality, never by suffix: a suffix match admits evil-nacre.work, which is the hole a phishing-resistant factor exists to close.

What changes without you doing anything is that an installation with no NACRE_2FA_KEY — which is most of them — now offers a second factor where it offered none. That is deliberate rather than a default slipping through: a security key stores a public key here and no secret, so there is nothing to seal, nothing a database dump could use, and no key to lose. Nobody is required to enrol one; the Security screen simply has a control it did not have. There is no flag to turn it off, because a flag whose only effect is to make accounts easier to take over is not a feature.

Two defects from 0.18.0 are fixed here, and both were invisible to a green suite. DELETE /v1/me/second-factor/{id} answered 404 for every id, so a second factor could be enrolled and never removed. And GET /v1/auth/methods answered 404 as well, because the sign-in surface accepted only POST — which is the endpoint the console reads to decide whether to show the “forgotten your password” link, so the link was hidden on every deployment, including the ones with a relay configured. Recovery itself worked throughout and was unreachable from the console. Verification is written here rather than taken from a dependency, and driven in a real browser through Chrome’s virtual authenticator, because this is attacker-supplied bytes on the sign-in path. One migration, 0031; existing TOTP rows are untouched and every one of them goes on working. Take the nacre image and run the migrator.

0.20.0 is released: an account several people hold can no longer take itself away from them. A login you publish — a demonstration printed on a page, a read-only account handed round a team — could until now enrol a second factor or change its own password, and the first holder to do either locks out every other one permanently: an administrator deliberately cannot remove somebody’s second factor, so the only repair is to reissue the credential. This was live on our own public stand, and it was found by asking the running stand rather than by reading the code.

The property is a column on the account, not a switch on the installation. Until 0.19.0 a deployment had an accidental guard — with no NACRE_2FA_KEY the whole surface answered 404 — and WebAuthn needs no key, so it went away, correctly: an installation may have one shared account and a hundred people. Mark such an account when you create it, with --shared on the command line or the checkbox in the console, and it has no /v1/me credential surface at all — no second factor, no password change, no reset link. An administrator still sets its password, which is how a published credential is rotated. It cannot be unmarked afterwards, because doing that on an account whose password is already out reopens the door to whoever holds it.

There is no migration for an account you have already published. The column defaults to false and nothing can guess which of yours are shared. If somebody has already enrolled a factor on one, that account is not recoverable as itself — create a replacement and publish that.

The other half asks something of anybody writing a client. A sixth extension point lets a commercial module require a second factor before it will mint a session, and the gate is consulted inside the one function that mints one, because a check placed beside the four paths that reach it is a check the fifth forgets. The open core registers no gate, so nothing changes on a deployment running no module — but POST /v1/auth/login, /v1/auth/second-factor, /v1/auth/refresh and POST /v1/me/password can now answer 200 with an enrolment challenge instead of tokens. A client that reads a 200 from those as “tokens are in the body” reads undefined for an access token the first time a customer turns a policy on. One migration, 0032, and no new variable.

0.21.0 is released: two additions for anybody writing a commercial module, and nothing at all for an operator — no migration, no new variable, nothing to do. A sign-in gate is now told whether the account in front of it can hold a credential of its own, so a policy cannot send a shared login to enrolment routes that answer 404 to it. And the migration runner takes an options object, so a module applies its own schema with the core’s runner and its own ledger instead of shipping a second copy of a checksumming, backfilling, transaction-per-migration runner. Both are additive; existing calls compile and behave identically.

0.22.0 is released: the enrolment screen finally hands its secret over, and the messages this product sends are in its own brand. Adding an authenticator meant retyping a thirty-two character base32 secret into a phone, from an alphabet with no 0, 1 or 8 — so a mistyped character is six digits that never work with no way to tell which half is broken. There is a QR code on that screen now, the secret has a copy control, and the otpauth:// link is shown in full rather than truncated into a tooltip a phone has no pointer for. The recovery codes can be copied and saved as a file from the one dialog they will ever appear in.

The QR code needs nothing from the deployment. It is encoded in the console itself — no image is fetched, no service is called, and the page’s script-src ‘self’ is unchanged. Written rather than depended on, because the thing being drawn is a credential; checked against an independent decoder rather than against itself, which is what caught three defects before the first symbol was scannable — one of them at exactly the length this feature uses.

Every message is now multipart/alternative: the same words in plain text, plus an HTML part in the product’s palette, both rendered from one description so neither can say what the other does not. There is nothing to configure and no image is loaded, so no message carries a tracking pixel. One thing is worth knowing before somebody asks: a message that asks for an action prints the link as its own URL beside the button, so a reader taught to check a link before pressing it can — which also makes a click-tracking relay’s rewriting visible to the recipient.

And a provisioning race is fixed, which matters if you script tenant creation. Two organizations provisioned at the same moment on an installation with no default embedding provider row could both try to insert one, and the loser failed outright — no organization created, and the error naming a constraint rather than a race. It took our own public stand down. Nothing to do on an installation that already has that row. No migration, no new variable.

0.23.0 is released: the access log has a screen. GET /v1/audit has been readable, cursor-paged and exportable since the journal landed — and nothing in the product showed it, so the only way to ask which documents did this agent read last quarter was curl with an Accept header, or psql. There is an Access log view now, with filters for the action, the result and a date range.

It offers no field for an actor’s id, deliberately. Nobody knows a uuid, and a mistyped one comes back as an empty log — which reads as nothing happened, and on an audit query those are opposite answers. Pressing an actor in the log narrows to that actor instead: the list is the picker, because every actor worth filtering on is already on the screen. An org_admin and a platform_admin still see the two different logs they always have, and the screen now says which — a log with no document reads in it is otherwise indistinguishable from an organization where nobody read anything.

The console also loads one file now. The web image ships an extensions.js that registers nothing; an image built FROM it may replace that file to add screens. On the open image nothing changes. Two things to know if you run your own front door: that path has to be served from the same directory as app.js rather than falling through to index.html, and from the same origin.

0.23.1 is released: one sentence. The message a platform administrator sees in a tenant’s console called the access log “the one exception”, which is a claim about the whole navigation and stops being true the moment an image adds screens to it. It names what that role reaches in this organization now. Nothing to do.

0.23.2 is released: also one sentence, and the more interesting of the two to state. One helper was added to what a console extension is handed, and the contract number moved for it — an additive change moves it, because an extension built for the newer number and loaded by an older console finds undefined where it expected a helper, which is a screen that draws nothing with an error in nobody’s log. Nothing changes on the open image and nothing to do.

0.23.3 is released: one screen looks different. The access log’s Result column rendered its three values at three type sizes — allow at the table’s own sans, deny inside a chip, error inside a tag — and a table is read down, so that reads as three kinds of thing rather than as three values of one field. It is one control at one size now; only the fill differs, and an allow still carries none, because it is what almost every row says. The repair is the check rather than the three edits: the console’s browser pass asks of every column of every table on every screen whether the cells agree, and it was run before the fix — it named exactly the access log and nothing across the other sixteen screens. Nothing to do, and no console extension is affected.

0.23.4 is released: the same screen, two columns, both reported by looking at a running console. The Result column’s three pills were three widths — one type size in 0.23.3 left the boxes ragged, and down a column a moving right edge reads as the values meaning different amounts of something. They are one width now, taken from the longest value. And the Actor column named nobody: it rendered the stored actor label, which every writer of an event builds as type:id, so a whole uuid sat above a shortened one where an operator expects an address. It resolves to an email or a service account’s name now, falling back to the actor’s kind as a word where it cannot. No API change and no migration — anyone reading GET /v1/audit directly sees exactly what they saw before.

0.23.5 is released: the enrolment QR named a URL where an account should be. An authenticator showed https://playground.nacre.work: //playground.nacre… — the issuer, a colon, and then the tail of a URL standing in for the address, on the one screen whose job is to say which account you are looking at. otpauth:// labels are issuer:account, so the colon is the separator and neither half may contain one; percent-encoding is no way round it, because an app that decodes before splitting sees the colon again. The issuer is the canonical URL’s hostname now — the same value the WebAuthn relying party has always taken, three lines further down the same object and for the same stated reason. The repair is a refusal rather than the one edit: the builder throws on a colon in either half, and the second-factor store makes that call once when it is constructed, so a deployment that reintroduces a URL is a container that does not start rather than a label somebody lives with for as long as that authenticator exists. It named five call sites still carrying the old value on the way in. Nothing to re-enrol — a code is HMAC(secret, step) and the label is not an input to it; an entry enrolled before this keeps working and keeps its old name until somebody removes and re-adds it.

0.23.6 is released: one method on the object-storage client, and nothing an installation sees. It can list a bucket now — the header said nothing needed to enumerate one, which was true of every caller the open half has and stopped being true for an archive whose verification refuses a part its manifest does not name. Paginated to the end rather than bounded, because a truncated listing makes that refusal quietly weaker instead of failing. Nothing to do.

0.23.7 is released: object storage works on Node 24, and did not. The client set content-length on every PUT and signed it — a forbidden request header, which the Fetch standard has the runtime compute from the body. undici 6 dropped one set by hand; undici 7, which is Node 24’s, throws before the request leaves. So on that runtime every write to a bucket failed: an ingest of a PDF, and every backup written to one. Nothing was signed away with it — what binds the body is x-amz-content-sha256, which is in the canonical request either way. Found by running the client under a runtime that had already moved rather than by reading a changelog. Nothing to do on Node 22.

0.23.8 is released: a restore survives a blip. The object-storage client had no retries on a stated argument — that its callers, the ingest queue and the collector, already retry whole units of work. True of them, and false of the caller that arrived afterwards: verifying or restoring a backup reads 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. Three more attempts inside a thirty-second budget, on a transport failure, a 5xx and a 429, and nothing else: a 403 re-signs with the same inputs and arrives at the same refusal, a 404 is an answer, and 501 means the store does not have the operation. Full jitter over the whole window, because worker replicas share a bucket and a blip they all see is one they would all retry from at the same instant; Retry-After honoured and capped. A readiness probe is the one caller that still makes exactly one attempt — retrying inside it turns “the bucket is not answering” into no answer at all, which an orchestrator reads as a pod to kill. Nothing to do; expect one new warn line where a request was retried, because a retry that happens silently is a system that got slower for a reason nothing recorded.

0.23.9 is released: two things in the admin console, both found by rendering a screen and looking at it. A select asking you to choose said pick a organization… — the article was built by hand from the noun, and computing it from the first letter would fix that and break user, which the same control also takes. There is no article now: 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 the console’s stylesheet at all, so a disabled primary button rendered in full colour and a press did nothing. That breaks a rule written three lines away, about a select with one option: a control that cannot be operated still invites operating it. Both are held by a check now, and writing it exposed that the rule beside it could not fail. Nothing to do.

0.23.10 is released: reranking, if you configured it, was never running. The search path sends its whole candidate set to the reranker in one call — NACRE_RERANK_CANDIDATES, 50 by default — and Text Embeddings Inference refuses a client batch over --max-client-batch-size, whose default is 32. The full and airgapped Compose profiles passed no such flag, so every search with more than 32 candidates was answered 413, from the day reranking landed. Nothing broke, which is the whole difficulty: reranking fails open on purpose, so the search still answers in fusion order with a counter and a log line, and an installation that configured a reranker simply got searches that were never reranked. Found by sending the shipped default to a real server rather than by reading — the same way the embedding batch defect was found, and the same shape. Splitting the batch is the wrong repair and the documentation already said why: 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. So the server is told to accept what the product can send, and a check holds the two numbers against each other, both read out of the files that state them rather than written down. Something to do if you run those profiles with a reranker: docker compose --profile full up -d --force-recreate reranker, because Compose does not restart a container for a changed command. If you point at a reranker of your own, give it the same flag or lower the candidate count to its limit.

0.24.0 is released: a second full audit of the tree, and the worst of it lived on paths no suite structurally sees. Twenty concurrent session refreshes could stop the whole API: the refresh path held one pool connection while awaiting a second, the pool has no acquisition timeout, so the twenty-first request waited forever and so did everything after it — the connection is threaded through now, and a test on a pool of one proves a refresh completes where it used to hang. Deleting a document and re-ingesting the identical content left it invisible forever while reporting indexed — resurrection matched none of the requeue predicates, so the index kept the tombstone; every resurrection requeues now, and re-sending such a document is the repair for one already in that state. The collection copy a model migration runs had no claim, so scaled-out workers raced it — it is a lease plus a fencing token now, the writers that cannot pause are repaired after the pointer moves, and the worker’s background sweeps keep running while the ingest queue is busy instead of starving behind it. The password-reset endpoint answered after the SMTP round trip, which made the one route needing no credential a timing oracle for whether an address has an account; the 204 comes before the work now. A locked authenticator code renewed its own lock on every retry, the correct code included. And ping was answered on one MCP transport and 404’d on the other for the whole life of both, because the parity suite’s guard compared its table against a literal list — it reads both dispatchers’ own case arms now. Every fix was measured red with the defect restored before it was believed. Nothing to do beyond upgrading, with one exception the upgrade notes carry: a document you deleted and re-sent unchanged before this release wants re-sending once more.

0.25.0 is released: a transient ingest failure was a permanent verdict, on the first attempt. An embedder restarting, a vector store that blinked, a parser timing out — every document in flight was written failed, and nothing retries failed. They sat there until somebody re-sent the bytes, while the layer went on answering searches out of whatever had indexed: quietly worse retrieval, with nothing in a log a person reads. The product already knew the answer and only half of it was asking — the classifier that documents “re-sending later may work” was consulted by the API, to explain a failure to a caller, and never by the worker, to decide what to do about one. It is executable now, in the file that defines the reasons, so the two cannot drift. A retry window (retry_after) is what makes a bounded retry a bound: without a “not before” time an embedder down for thirty seconds burns every attempt of every queued document inside one poll, which is five times the load on the service that is already failing and a permanent verdict anyway. A quota deliberately does not retry, because its remedy is an administrator raising a limit. POST /v1/documents/{id}/retry is the way out of what is left, for the caller who no longer has the bytes. And two SECURITY DEFINER functions could be shadowed by a temp table — in the two migrations written to prevent exactly that, because omitting pg_temp from a search path does not exclude it, it moves it to the front. Measured both ways against a real PostgreSQL with the product’s own trigger.

0.25.1 is released: the attempt counter an operator reads was one behind, found by running the released image. The claim documents itself as “including this claim — the claim statement increments it”, and it did not: the read happens before the increment. The bound was never affected, which is why nothing failed — the verdict is written from the row’s own post-increment count, so the number of attempts a document got was always right. What was wrong was every number a person reads: the worker logged attempts: 0 on a first attempt beside the maximum on the same line, and the backoff’s exponent started a step low, so the first two attempts shared one ceiling instead of doubling. Found by pulling the published image, starting it against a parser that was not there, and reading its log — which is this project’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.

0.26.0 is released: a tenant administrator could steer the shared worker at the internal network, and every listing lied by omission at scale. Creating an embedding provider is gated on the organization’s administrator, and that role is the threat model: on a single-organization installation they are the operator and own the documents, so there is nobody to take anything from. On a multi-tenant platform they administer one customer and not the installation — so pointing the worker at the cloud metadata endpoint, the API beside it, or the vector store is a server-side request forgery from a privileged position, and metadata credentials are installation-wide, which makes it an escalation out of the tenant. The endpoint a tenant may name must now be a public https hostname — not an address literal, which closes the decimal and octal spellings a range table never sees — every address it resolves to is checked, and the three places that fetch a model refuse redirects, because a validated public host answering 302 is the same hole one step later. A deployment that provides embedding as a service can remove the surface entirely rather than bounding it. The listings were the quieter half: every client-side list 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. It walks to the end now, or refuses; it never returns a partial set that reads as whole. An organization’s administrator can also manage their own embedding models from the console at last, which the schema has offered since the first migration and no screen ever reached.

0.26.1 is released: every tool call from a current MCP client failed, and the catalog looked fine. The 2026-07-28 revision of the protocol makes resultType a requirement on every result, and the server wrote it on the discovery answer alone — the one result the transport tests quoted. A client speaking that revision listed six tools and then refused every tools/call answer as malformed, so an agent could see its layers and search none of them. Found by a real client, not by a suite: every case asserted the shape the server writes. The tools also carry MCP annotations now — read-only, destructive, idempotent, open-world — so a client can tell search from delete_document before calling either, instead of assuming the worst of both. And the build went red on main for a reason outside the tree: the object store image left Docker Hub, and the Compose full profile moves to another build of the same server.

0.26.2 is released: a tool that failed dropped the client’s connection. A missing document, a layer the caller may not read, an unknown tool — every one answered with an HTTP 404, and on the streamable transport a 404 means the session is gone. A client following the specification dropped its connection and started over, and a hosted client reported that it could not reload the server’s tools, after a single lookup of a document that was not there. A failed call is now the tool’s own answer — a result marked as an error — and it still says one thing, “Not found”, whatever went wrong, so it tells a caller nothing about what exists.

0.26.3 is released: the check on a tenant’s embedder now holds where the connection is made. 0.26.0 refused an internal address when the endpoint was saved, and the worker then looked the name up again when it sent — so a name that answered with a public address to the check and a private one to the request, or any endpoint saved before the check existed, still reached the network inside the installation. Every embedding request to an endpoint that is not the installation’s own is now judged by the address the socket actually connects to, and redirects are refused. The address test was rebuilt too: it had recognised only one of the several ways IPv6 can carry an IPv4 address. The parser closes the same gap for documents fetched by URL. The server now runs on Node 24, and every dependency is at its latest release.

0.26.4 is released: the MCP server is registered under the domain. Its name in the MCP Registry is work.nacre/nacre, verified by a record on nacre.work, rather than a name tied to a GitHub organization. Changed before the first publish, which is the only moment it costs nothing. Nothing to do on upgrade.

0.26.5 is released: the tool catalog is never served from a cache. tools/list carried a five-minute cache hint with a scope the protocol’s caching rules do not define — inert for as long as the result had no resultType, and honoured from the day 0.26.1 added one, so a client that obeyed it showed its cached catalog through every refresh for five minutes after each fetch. The catalog names the caller’s own layers, changes with a grant, and this server sends no change notification, so no copy of it is ever fresh: it says so now, in the two words a client acts on. Nothing to do on upgrade.

0.26.6 is released: every console list searches and pages, and a platform administrator no longer sees who read what. Layers, Grants, People, Service accounts and Connected applications have a search box and pages of fifty; the Access log offers the action names as they are recorded, and a User field turns an address or a service account’s name into the actor filter. Writing that list found the deny-list that keeps a platform administrator from being shown document reads naming actions nothing records, while both surfaces record a fetch as get_document — so every document fetch had been shown to the one role that must not see one. The actions are one catalogue now, and a check holds every writer to it. Records already written are unchanged; what that role is shown of them changes on upgrade.

0.26.7 is released: the console’s masthead grows. It was a fixed 56 pixels, and a console that loads commercial screens has twelve sections, which is more than one line holds at 1440 — the navigation spilled out of the bar and “Sign out” broke across two lines beside it. It has a minimum height now and the sections wrap inside their own box; a console with the core’s own sections alone renders exactly as before, all twenty screenshots byte for byte. Nothing to do on upgrade.

0.27.0 is released: Word, PowerPoint, Excel, OpenDocument, EPUB and RTF are accepted beside PDF. The rules are the PDF path’s: the part declares the type and the bytes carry the family’s signature, a file needs object storage to live in, and the index extracts the text. The table of formats is one table in the core that the edge, the worker and the parser all read, because the PDF path was one format with its name written in three processes, which agree exactly as long as there is one. The parser carries a second extractor for it — Firecrawl’s converter, pinned, with no models and no network — and PDF deliberately stays on the extractor it had, moved twenty releases forward in the same change, because the newcomer calls two of the hostile PDF shapes the sidecar is tested against “needs OCR”, which is the wrong thing to tell an operator about a broken file. Legacy .doc, .xls and .ppt are deliberately not in the table. The SDK uploads files now. One migration; and an API at 0.27.0 in front of a parser still at 0.26 accepts a Word document at the edge and fails it in the worker until the parser moves, which is the mixed-version window and not a defect — the document retries on its own.

0.28.0 is released: the MCP server runs on the reference SDK, and a file reaches the index without passing through a model. Both transports serve the 2026-07-28 revision through @modelcontextprotocol/server rather than two hand-written dispatchers that each remembered the protocol separately and diverged on five things over their lives; every shipping client is a legacy-era client and that era is served as it was. POST /v1/uploads mints a single-use, five-minute ticket for a layer the caller may write to, and the bytes go to the ticket as the raw body with no credential, because the ticket is one — over MCP the request_upload tool hands the model a curl line instead of asking it to retype the file into an argument. Three MCP App views ship with the server: search and list_layers open the permitted result set and the catalog in a host that renders apps, and upload_file opens a file input whose bytes never enter the conversation. They are drawn in the brand, at one control height, and the host decides only the theme. get_document links the original bytes as a resource_link. No migration and nothing to configure; a 2026-07-28 client is held to its own revision’s headers, which the SDK’s client already sends.

0.28.1 is released: the images are on Docker Hub too, and the layers panel’s More button is gone once there is nothing more. Every image the release pushes to ghcr.io is pushed by the same build to nacrecontextlayer/<image> at the same tags; ghcr.io stays the canonical address, because Docker Hub rate-limits pulls by account. The MCP layers view hid its More button with the hidden attribute, which the brand’s button rule beat — so it stayed on the screen after the last page and every press appended the first page again. Nothing to do on upgrade.

0.29.0 is released: an agent that connects already knows how to work here. A skill — Claude’s own folder format, a SKILL.md and its references — at three levels: the installation’s, the organization’s, which replaces it, and a layer’s, which is added. The base skill arrives in the MCP instructions before the first call, a layer’s is read with get_skill before writing there, and every level is written through /v1/skills as JSON or as the .zip Claude exports. Every write is a version naming the one it replaced — a stale one is refused, not silently overwritten — and a version an agent wrote, or one carrying scripts, says so. A layer’s skill is visible exactly when the layer is, so the listing is not a directory of layers; that is held by five new authorization cases against a real database. One migration, nothing to configure: an installation nobody has configured hands every agent a default skill.

0.29.1 is released: what every agent is told first is the server’s own guide, and no skill can replace it. How the tools work — a file goes through request_upload, the same external_id replaces a document, queued is not indexed — had been written into the default skill, and an organization’s own skill replaces that one entirely; the first organization to write its conventions would have taken the mechanics from every agent it has. They are the guide’s now, and a skill keeps what only the organization knows. Nothing to do on upgrade.

0.29.2 is released: a connected application is shown only the tools it may use. A person who approves a read-only connection has approved a search client, and that client used to be listed delete_document and update_skill anyway and refused each on every call. The catalog now leaves out every tool above the ceiling approved on the consent screen, using the same check every call already passed — so a write-only connection does not see search either, because write does not imply read. A service account or a person’s own token is still listed every tool, since what they reach moves with grants between calls. Nothing to do on upgrade.

0.29.3 is released: the Helm chart is in the open repository, and every release carries it. It had lived in a private infrastructure repository with a version of its own, which is how it drifted three releases behind the images it named. It is Apache 2.0 at deploy/helm now. Its version and appVersion are the release’s, and the packaged chart is attached to every GitHub release, so helm install takes one URL per version. The four runbooks about operating the product came with it, into docs/operations, re-checked against the code on the way. Nothing to do for Compose.

0.29.4 is released: a document re-sent while its layer moves to another model is not left behind. A re-ingest replaces a document’s points, and nothing cleared the mark the move had already set on it. So it counted as migrated without the new model’s vector, and once the layer switched it stopped matching dense search, with no error anywhere. A re-ingest clears the mark now, and the move marks a document only if its points are still the ones it wrote to. If a layer finished a move while documents in it were being re-sent, rebuild-collection is the remedy.

0.30.0 is released: skills are on the console. What every agent is told about an organization, and about each layer, could only be written through the API or by an agent. A Skills screen now shows the organization’s skill and every layer’s. Each one has its history, its files as a folder tree, each file as rendered Markdown or as source, and markers for a version written by an agent or carrying scripts. Whoever may write a level can edit a file, load a folder or a .zip, clear it, or restore an older version. A skill’s text is never rendered as HTML, because it can be written by an agent and read by an administrator. The console is now also measured at a phone’s width on every change, which found the access log breaking an address mid-word. Nothing to do on upgrade.

0.31.0 is released: an application can be allowed to edit one layer’s skill, and nothing else. The consent screen gains an Edit skill box on each layer the person administers. A token from that screen reaches the REST API as well as MCP, so offering admin there would have let an MCP client rename a layer, delete it and grant on it. The box stores a value that is not a permission: it allows writing that layer’s skill where the person is an administrator, and one file in the API is the only code that reads it. Writing it found the layer list leaking past a connection’s restriction. A connection narrowed to one layer was told the name, description and document count of every other layer its person reads, over both REST and MCP. MCP’s list also ignored the connection’s permissions. Both now list what the connection reaches. The connections list had never matched its own contract, so it read as [object Object]; it does now, and the Connections screen says under each application what it may do. Migration 0037; nothing else to do on upgrade.

0.31.1 is released: a connected application can no longer approve a wider connection for itself. A connection made on somebody’s behalf carries that person’s token, and the REST API accepts it, including on the endpoint the consent screen’s Approve button calls. So an application connected read-only could register a second client and approve it with no restriction at all, getting everything its person reaches in one request. Approving is now something only a person signed into the console can do; the same request from a connected application answers 404, and a new leak case (T41) holds it. Upgrade promptly. To see whether it happened, look at Connections for a client its person does not recognise.

0.32.0 is released: an organization administrator can ask an agent about the organization, and the agent cannot change anything. A second MCP endpoint, /mcp/admin, with its own token: a connection to it is approved by an administrator as themselves, and its token is refused by the API and by the ordinary MCP endpoint, as theirs is refused there (leak case T31). Thirteen tools read people, groups, layers, grants by person or by layer, what anybody can reach as the permission model computes it, skills, connections and the access log, with counts done in the database; four prompts walk through an access review, who read a document, why somebody is denied, and a layer’s health. It follows no skill: a layer’s skill is written by somebody with less authority than the administrator this surface acts for, so it comes back marked as text under review (T39). Every request made through a connected application now records the connection in the access log, on every surface, and the Connections screen labels each row MCP or admin MCP. Migration 0038.

0.33.0 is released: a skill opens as a panel inside the agent. In a client that renders MCP Apps, get_skill and list_skills show the skill the way the console does: its files as a folder, SKILL.md rendered and as source, who wrote the version and whether it carries scripts. Where the person may write that skill, the panel loads a folder or a .zip, read by the same bounded reader the API uses. The script that renders every panel in a real browser is now part of the build. A note in the project had described it for three releases while nobody ran it. Nothing to do on upgrade.

0.34.0 is released: an administrator’s agent proposes a change, and the person applies it. Eighteen tools on the administrative MCP change people, groups, layers, grants, skills and connections. Each tool proposes: it says what would happen and changes nothing. The change happens when the person presses Apply, either in a panel beside the conversation or on the console’s new Proposals screen. A proposal applies once and expires after ten minutes. The panel presents a key that the model never sees, so an agent that finds a proposal’s id in the access log still cannot apply it. A full review found that exact hole in the first version, before it shipped (T32). Revoking a connection cancels what it proposed, and refused attempts are recorded. Commercial modules add their own tools through a new extension point, registerMcpTools, and their writes are proposals too. The console’s Connections screen now shows where to connect: the MCP address, the administrative MCP address for administrators, the REST API, and example requests that run as written. These come from a new GET /v1/endpoints. Migration 0039; check that NACRE_CANONICAL_URL is the address your users reach.

0.35.0 is released: an administrator’s agent can send mail, and only to people in the organization. Where the installation has a mail relay, send_notification proposes an email to chosen people or to every organization administrator. Alert rules email the people they name when an agent writes a skill, a skill version adds scripts, an administrative connection is approved, one principal is denied repeatedly, or documents fail to index. The worker checks the rules every minute with no agent connected. Recipients are checked three times: when the agent proposes, when the person applies, and when the worker sends. Each time the recipient must be an active person in the caller’s organization. What is stored is a user id, never an address, so there is nowhere an outside address could go (T35). A notification carries no links and says that an agent wrote it and who approved it. Each organization can send at most thirty an hour, and the access log records every message whatever became of it. Migration 0040. If you use the Helm chart, the worker now receives the mail relay; under networkPolicy.strict, allow the worker to reach it.

0.36.0 is released: an administrator’s agent shows its answers as panels. In a client that renders MCP apps, the access log, connected applications, what one person can reach, and a layer’s health each open beside the answer. You can narrow the log to one person, page through it, and revoke a connection from the panel; a revoke is a proposal you apply, as before. The new layer_status shows how many documents are indexed, pending and failed, why recent ones failed and whether each will recover by itself, and a reindex with its recall gate. It does not retry documents: the administrative connection changes no documents. The built-in guide and the default skill now say that a document found by a search is updated under its own external_id, which get_document gives, so an agent no longer adds a second copy. No migration.

0.36.1 is released: a security fix in a dependency. The vector database client pinned undici 7.29.0, which has two high-severity advisories, one of them a TLS certificate check that a dropped connection can skip. The images now carry 7.30.0. If you install the npm packages into a project of your own, add the override described in the upgrade notes. No migration.

0.37.0 is released: access is changed from a panel. On the administrative MCP, list_grants opens a panel: who has what on which scope, a Revoke on every row, and, when the listing was about a layer or a workspace, a form to give access there. Every press is a proposal the person applies, as before. A commercial module’s listing can now open the same panel: the acl-advanced module’s list of document grants and deny rules does, with a document field, so a document’s access is set from the conversation. The open core draws the panel and checks that every button a module offers is a proposal. No migration.

0.37.1 is released: the Connections screen by role. “Connect a client” shows a member the MCP endpoint and how a client takes it. The administrative MCP and the REST API, with its example requests, are shown to an organization administrator: every REST example needs a service account key, which only an administrator can mint. Nothing to configure.

0.38.0 is released: a security release. A full review of every surface found fifteen things and this fixes all of them. A connection limited to some layers could administer the others, and an organization administrator’s could administer the organization itself. A document deleted or re-sent while it was being indexed could come back in a search. An upload ticket kept working after its connection was revoked. A once-shown password or key no longer sits in the idempotency cache. The console refuses to be framed by another site. The parser shares a network with the worker and nothing else. Every finding was reproduced before it was fixed. One migration, applied as usual. The upgrade notes name the three things an operator might have to check.

0.38.1 is released: the Layers screen by permission. It offered Rename, Model and Delete on every row to whoever could see the row, so a member who only reads was shown three controls the server refuses. Each now appears only where the caller may use it. Found by walking the console as a member to write its manual. Nothing to configure.

0.38.2 is released: four console fixes, each now a rule. People's actions sat mid-row; the access log printed whole ids where every other screen shortens them; "No second factor" left its buttons off-centre; and four controls — two pickers in Issue a grant among them — had no name a screen reader could say. Each is fixed and each is a rule in the shared layout checks, so the commercial console is held to it too. Nothing to configure.

0.38.3 is released: Search offers Delete only where you may delete. Every result carried a Delete button, which needs write access to the layer; a member who only reads no longer sees one. Nothing to configure.

0.39.0 is released: rebuilding a search index that survived a restore. Restoring the database onto an installation whose vector store kept running left the index disagreeing with it, silently: a document the backup held was never found, and points of newer documents took places in a search’s results that nothing filled. Found by restoring a backup on a running stack. rebuild-collection takes --replace for that case and is otherwise unchanged, refusing a collection that exists. The rebuild is a library function now, so a restore on a running installation can call it too. Nothing to configure unless you restore; the restore runbook says when to pass the flag.

0.39.1 is released: an MCP server that kept every request, and a search that read the whole organization. Both found by putting the running stack under load. The MCP transport grew by about 80 KB on every request and never gave it back, until it ran out of memory; it compiles each tool’s schema once now, and serves a search at about half the CPU it did. And the last step of every search looked its hits up by a column with no index, walking every document of the organization to find ten — a new index and a query that starts from the hits took the same load from 65 to 125 searches a second. Upgrade the MCP transport promptly; the migration takes a short write lock, and the upgrade notes say how to avoid it.

0.40.0 is released: linking people and groups to an identity provider without SQL. Single sign-on finds a person by the subject their identity provider signs them in as, and a directory sync finds a group by its directory id — and nothing in the product could set either, so setting up SSO started with psql. Found by setting it up with nothing but the console. The People screen, the nacre command and the API set both now, and one id names one group. The migration refuses if somebody linked two groups to one id by hand; the upgrade notes say how to find them.

0.41.0 is released: a budget per caller, registration bounded, and a client identified by its own metadata document. Only search and ingest were ever counted, so an agent in a loop over a listing — or over the MCP tool list — was limited by nothing and could take the API away from everybody beside it. Every request now counts against the person or agent making it, one budget across REST and MCP, ten a second sustained: enough to stop a loop and nothing a person or a working agent does. The window slides, a refused request is not counted, and the wait it is told is the time until the next request fits. Client registration was open with no limit and nothing ever removed a client nobody approved; it is limited per address now, and an unapproved client goes after a week. And an MCP client may now be identified by the URL of its own metadata document instead of registering — fetched through the same guard that keeps the embedder off the internal network, and named on the consent screen by the domain that served it. Registration still works beside it. An air-gapped installation turns the fetch off with one variable; the upgrade notes say which. And an installation serving several organizations can hold each to a total of its own, through a new extension point: the commercial tenancy module gives every organization a default and lets the platform administrator set any one’s limits beside its quotas.