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.