SPEC — invariants and threat model
What Unsay guarantees, where each guarantee is enforced, and what asserts it.
Every claim below was checked against the source before it was written. References are file › function rather than file:line, because line numbers drift and a stale line number is a lie with a plausible shape. If a claim here is not greppable in the file it names, it is a bug in this document — file it.
References span two trees, and that is the one thing this document got wrong for a day: the generic mechanism — the store, the chain, the partition, the notifier — was lifted into packages/live-resources/ on day 7, and every src/store.ts › … reference here went on naming a file that had become a 116-line adapter. src/ is now the care-specific half (server.ts, http.ts, seed.ts, envelope.ts, retraction.ts); packages/live-resources/src/ is the half that knows nothing about hips. Both are named explicitly below.
Two things this document does deliberately, because eight prior submissions in this builder's history lost points for documenting behaviour that was never built:
- it names, in Coverage gaps, every place where an invariant is implemented and something about it is still unasserted — including the two that are proven by a script rather than by
npm test; - it has a what we do not defend against section, and a not built section, and both are longer than a marketing document would allow.
Vocabulary
| Term | Meaning here |
|---|---|
| principal | { sub, scopes[] } — who is asking. src/types.ts › Principal |
| speakable | audience user; URI scheme care://; scope care.read.user |
| internal | audience assistant; URI scheme care-internal://; scope care.read.assistant |
| revision | a new version appended to a domain's chain; fires notifications/resources/updated |
| the speech window | 3400 ms — a 12-word utterance at ~150 wpm. An assumption, declared as a constant in scripts/bench.ts, not a measurement of Alexa+ TTS |
The scheme→audience→scope mapping is two tables, SCHEME and SCOPE, in src/types.ts. They are the only place that mapping exists; nothing else in the codebase puts a scheme string next to a scope string.
Invariants
I-1 · Internal content is unreachable by a user-scoped principal
Statement. A principal whose scopes do not include care.read.assistant can obtain the content of an audience: "assistant" record — text or blob — through no code path in this repo.
Why. "Fall risk: HIGH. … Family disputes the discharge plan" (src/seed.ts) must shape the assistant's answer and must never reach Ray's ears. That is a safety boundary, not a rendering preference, so it cannot depend on the client behaving.
Where enforced. packages/live-resources/src/store.ts › read() — two checks, deliberately redundant:
- the scope required by the URI's scheme, via
SCOPE[parsed.audience]; - the scope required by the record's own audience, after lookup.
Check 2 exists because check 1 trusts the URI the caller supplied: a caller asking for an internal record under a care:// URI passes check 1 and fails check 2.
Check 1 earns its place in the mirror case, and it is not decorative. Chains are keyed by subject and topic, not by audience, so care://ray/risk resolves to the same record as care-internal://ray/risk. A principal holding care.read.assistant and not care.read.user passes check 2 — it may read that record — and check 1 is the only thing that stops the internal value being served under a scheme a host is entitled to speak. Removing it used to leave the whole suite, npm run verify and npm run e2e green; the test named below is the one that fails now.
Everything funnels through read(). staleness() calls it. src/server.ts calls it for resources/read and again in the resources/subscribe handler before recording a subscription. Blobs ride the same path by construction: src/blobs.ts is a byte registry keyed by URI that carries no authorization logic at all, and blobFor(uri) is only ever reached after store.read() has already returned — so the partition covers audio without a second, drift-prone check. src/http.ts › handleVerify() is public and therefore defaults its principal to { scopes: [SCOPE.user] }, so the open port lists speakable chains only.
Asserted by. test/store.test.ts — "hides assistant-only records from a user-scoped principal", "will not let a care:// uri reach an assistant-only record", and "serves assistant-only records to a principal holding the scope" (the partition must be a partition, not a wall). test/envelope.test.ts — "still hides assistant-only records from a user-scoped principal", i.e. encryption did not quietly change the answer, and — pinning check 1 independently of check 2 — "refuses a care:// uri for an internal record even to a principal that could read it internally". scripts/verify.ts §1 exits non-zero if either internal domain is readable with care.read.user.
I-2 · The existence of internal content is not leaked either
Statement. For a user-scoped principal, a forbidden URI and a nonexistent URI are indistinguishable in error code and in message shape.
Why. 403 on care-internal://ray/risk and 404 on care-internal://ray/nonsense together are an oracle: a patient-facing token could enumerate which clinical concerns exist for a patient without ever reading one. The list of a patient's problems is itself the disclosure.
Where enforced. packages/live-resources/src/errors.ts › NotFoundError — JSON-RPC -32002, message Resource not found: ${uri}, raised identically for a wrong scope, an unparseable URI, an absent chain, and an absent version. packages/live-resources/src/store.ts › list() filters by scope before building the array, so an unauthorized URI is never enumerated — which also means it can never appear in a pagination page or a cursor.
The same rule is applied to both notification channels and to the replay of either, which is the easy place to forget it. src/server.ts › logRevision() re-parses the URI and returns early unless the principal holds the scope for that audience, before anything reaches notifications/message. The updated channel does the same at SEND time through the notifier's canNotify guard (packages/live-resources/src/notifier.ts), so a subscription authorized under a wider scope stops delivering the moment the session's principal narrows.
Guarding the live path alone was not enough, and for a while that is all this build did. A frame is stored when it is sent, and Last-Event-ID hands it back later: a session that resumed under a narrowed token of the same subject was replayed care-internal://ray/risk, its version, its author, its timestamp and both hash-chain links, straight past both guards. src/http.ts › MemoryEventStore.replayEventsAfter() now re-authorizes every stored frame against the resuming principal — replayAllowed() reads the URI out of params.uri, params.data.uri, result.contents[].uri and result.resources[].uri, and fails closed on a URI this partition did not issue. Announcing "care-internal://ray/risk changed" on either channel, live or replayed, would confirm exactly what read() refuses to confirm on a third.
Asserted by. test/store.test.ts — "reports NotFound rather than Forbidden, so existence is not confirmed" substitutes the domain name out of both messages and requires them to be equal; "omits assistant-only URIs from list() for a user-scoped principal". test/http.test.ts — "withholds an internal URI from a resume whose principal has narrowed" drives the whole leak over real sockets (subscribe wide, drop the stream, resume narrow) and asserts the wide resume still replays, so the guard is shown to drop frames on scope rather than because replay is broken; "reads the URI out of every frame shape that carries one" and "fails closed on a URI this partition did not issue" pin the predicate itself. scripts/verify.ts §2.
Residual. Timing. read() does more work for a record that exists than for one that does not, and nothing here constant-times that path. For an in-process Map the difference is sub-microsecond and swamped by transport jitter, but it is not zero and we do not claim it is.
I-3 · A fact's audience can never be changed by a later write
Statement. Once a domain's v1 is published with an audience, every later version of that domain has the same audience, or the write is refused.
Why. Without this the partition is a lock with the key taped to it: republish risk as audience: "user" and content that was unspeakable a second ago sits on a care:// URI every patient-facing token can read. The boundary has to be a property of the fact, not of the most recent write to it.
Where enforced. packages/live-resources/src/store.ts › publish() compares prev.audience with input.audience and throws before any state is mutated — a refused write leaves no partial version behind. The HTTP write path does not re-implement the rule; src/http.ts › handleWrite() lets publish() throw, answers 409, and appends an audit row with reason store_rejected.
Asserted by. test/store.test.ts — "refuses to change a record audience on a later write". scripts/verify.ts §5.
I-4 · annotations.audience is never the control
Statement. No authorization decision in this codebase reads an annotation. The control is the URI scheme bound to an OAuth scope, decided server-side from the stored record.
Why. Two independent reasons; either alone is sufficient.
- The spec makes it advisory. MCP 2025-11-25 defines
annotations.audienceand its two legal values and places no obligation on a client to honour it. A compliant client may read anaudience: ["assistant"]resource and speak it verbatim.FRICTION.mdF-002. - On a read it does not even arrive.
ReadResourceResult.contentsis a union ofTextResourceContentsandBlobResourceContents, and neither schema has anannotationsfield — unknown keys are stripped. A server that attaches annotations to a read result watches the client parse them away. Measured against a live client, not assumed.FRICTION.mdF-005.
So the annotation is emitted as a correct-by-convention hint where it survives (resources/list), emitted on reads too for non-SDK hosts that pass JSON through, and is load-bearing nowhere. The load-bearing copy on a read is _meta — unsay/version, unsay/versionHash, unsay/prevHash, unsay/audience, unsay/lastModified, unsay/stale — which ResourceContents does define and which therefore actually reaches the host.
Where enforced. By absence, which is the only way to enforce a negative: grep -rn "annotations" src/ returns two call sites in server.ts that attach the builder's output to a list entry and to a read content, the declaration on the ui:// card (src/ui_resource.ts), a type re-export, and comments. The builder itself, packages/live-resources/src/store.ts › annotationsFor(), is one object literal. None is a conditional. No branch in either tree has an outcome that depends on an annotation.
Asserted by. Every I-1 and I-2 assertion, structurally: they exercise LiveResourceStore, where annotations are produced and never consulted, and still fail closed. The grep is the direct check and is cheap to re-run.
I-5 · A revision is provable, and a tampered version is located exactly
Statement. Each version carries versionHash = SHA-256(prevHash ‖ JSON(value) ‖ writtenAt ‖ authorId) and prevHash. Replaying a chain either returns intact: true or names the first version that disagrees with its recomputation.
Why. "The physio changed this thirty seconds ago" is a provenance claim an assistant is about to speak aloud to a patient. A claim a judge cannot recompute is marketing. Binding authorId into the hash means a substituted author breaks the chain too, not only a substituted value — the who is as load-bearing as the what.
Where enforced. packages/live-resources/src/chain.ts › hashVersion() and packages/live-resources/src/store.ts › verify(). publish() computes each hash from the previous version's hash, so the chain is built at write time and never reconstructed from a mutable field.
The hash covers plaintext, always — deliberately, and the reason is in the code comment: a ciphertext carries a random IV, so hashing stored bytes would change on every re-seal and prove nothing about what the clinician wrote. Hashing plaintext also keeps verify() meaningful for an auditor holding the audit log and no key. Under an envelope, verify() opens each version first; bytes that fail the GCM tag are reported at the same coordinates a hash break would use, so callers have one failure shape.
Asserted by. test/store.test.ts — "detects tampering and locates the version" (mutate v1 through the _tamper seam, then require intact === false and brokenAt === 1), "chains v2 to v1", "produces a different hash when the author differs", "is deterministic for identical inputs". test/envelope.test.ts — "keeps the version chain intact through encryption", "hashes the PLAINTEXT, so the chain is identical encrypted or not", "catches a flipped bit in stored ciphertext at verify time". scripts/verify.ts §3 corrupts a real seeded chain and fails if the corruption is not detected. src/http.ts › handleVerify() exposes the same replay on a public route.
Limit. A hash chain is detection, not prevention. Prevention against an attacker who owns storage is I-6.
I-6 · A ciphertext only opens in the slot it was sealed in
Statement. When an envelope is configured, a record's value is sealed with AES-256-GCM under AAD patient|domain|v{version}|audience. Bytes moved to any other record, audience or version fail to authenticate.
Why. I-1 is enforced at read time by scope. That holds against a client. It does not hold against an attacker with write access to storage, who can copy care-internal://ray/risk's bytes into care://ray/weight_bearing and have the server read Ray's fall-risk assessment out loud to him. Binding the record's identity into the AAD closes that: the partition survives an adversary who owns the database.
Where enforced. src/envelope.ts › recordAad() builds the AAD and reserves |, throwing rather than letting two distinct identities collide on a component containing the separator. Envelope.encrypt/decrypt set it as GCM AAD. packages/live-resources/src/store.ts › #seal()/#open() are the only paths in or out, and every accessor that hands out a record goes through #open().
Two providers, both real. LocalKeyProvider derives data keys with HKDF-SHA256 from UNSAY_MASTER_KEY and is complete with no AWS account. KmsKeyProvider makes SigV4-signed GenerateDataKey/Decrypt calls to KMS over fetch and refuses rather than degrades — an unconfigured KMS provider throws a typed error naming every missing variable, and never silently falls back to local, because the startup line must not lie about where key material lives. With no provider configured the store is plaintext and startupLine() says the word PLAINTEXT.
Asserted by. test/envelope.test.ts — "REFUSES a ciphertext pasted into another record", "REFUSES the same record under a different audience", "REFUSES a ciphertext replayed onto a different version of the same record", "DEFEATS a cross-record paste in storage itself", plus flipped-bit tests on ciphertext, tag and IV, truncation, non-envelope bytes, nonce uniqueness, key isolation across master keys, and the KMS provider's refusal to start unconfigured. The store-level tests assert that no seeded record — the never-speakable one specifically — is recoverable from at-rest bytes.
Limit. An attacker holding both storage and the data key can re-seal anything under the correct AAD and recompute the chain. AAD binding defeats relocation, not full compromise.
I-7 · A stale fact announces its own age instead of being spoken with confidence
Statement. A record past its staleAfter is served with its age and its author in the text body, prefixed [STALE — …; say this age aloud], computed server-side.
Why. The failure this product exists to prevent is a confident sentence about an expired fact. Ray's anticoagulant record carries a stop date four days behind whatever clock the seed is built on, and the sentence names that date in words (src/seed.ts); read flat, it instructs a 68-year-old to keep taking a drug he was told to stop. Both halves move together on purpose: the staleAfter was made relative to the injected clock while the sentence stayed pinned to "4 October 2026", so on every day but one the live server flagged the record as past its review date while its own words named a stop date still in the future. scripts/verify.ts §9 now reads the sentence, not only the header.
Why the age is in the body. By I-4, an annotation does not survive a read, so a design that put the age only in annotations.lastModified would announce nothing. Putting it in the text means the model cannot receive the fact without receiving its age. _meta['unsay/stale'] carries the machine-readable copy alongside.
Where enforced. packages/live-resources/src/store.ts › staleness() computes age and the boolean against an injected now; src/server.ts's resources/read handler builds the header and prepends it to the value; the brief_carer prompt appends — PAST ITS REVIEW DATE, say its age aloud to any stale line.
Asserted by. test/store.test.ts — "flags a record past its stale_after", "does not flag a record with no stale_after", "computes age from writtenAt". scripts/verify.ts §4 asserts both directions on seeded data — a checker that flags everything proves nothing. scripts/e2e.ts asserts the delivered text actually startsWith('[STALE') after a full round trip over Streamable HTTP, and exits non-zero if not.
Clock note. Staleness is computed from the server's clock, never the client's, so a host with a wrong clock cannot make a stale fact look fresh. The write path is stricter still: src/http.ts › handleWrite() sets writtenAt from the server clock and ignores any value in the body, because a writer who could set it could backdate a correction — and "how old is this instruction" is a spoken safety claim.
I-8 · A creation is not a change
Statement. The first version of a domain fires notifications/resources/list_changed. Only version 2 and later fire notifications/resources/updated. Seeding fires neither.
Why. updated is the signal that makes an assistant stop mid-sentence and retract. If creation fired it, the assistant would retract a sentence it never said about a fact it never had — the product's one dramatic moment spent on noise. A client subscribed to a URI that did not exist a moment ago also has nothing to re-read against.
Where enforced. packages/live-resources/src/store.ts › publish() branches on whether a previous version exists. src/server.ts arms list_changed only after resources/list has actually been SERVED — notifier.armListChanged() in the list handler — so the records seed() writes at startup are silent.
History, because the old mechanism is still worth knowing. An earlier draft of the paragraph above named a microtask flag. That mechanism is gone and the reason it went is F-012: a notification sent before any transport is attached fails silently, so the flag was untestable — the test written to prove it worked would have passed with the guard deleted. Arming on the first list actually served is the notification's own meaning, and unlike the flag it is observable.
Asserted by. test/store.test.ts — "fires updated only where seeding actually supersedes something", "fires updated on a revision, with the unversioned uri", "does not apply the staged revision"; test/server.test.ts — "stays silent about list changes until a list has actually been served" at the package level, and the seeded-with-a-host-attached case in this repo's server suite.
I-9 · A notification carries a URI, never content
Statement. notifications/resources/updated delivers only the unversioned URI. The new value is obtained by the client issuing resources/read, which re-enters I-1.
Why. If the notification carried the value, the authorization decision would move to send time on a subscription created earlier, and a scope revoked in between would deliver content the principal no longer holds. Carrying only the URI means every byte of content passes through read() exactly once, under the caller's scopes at the moment it asks.
Where enforced. packages/live-resources/src/store.ts › publish() calls listeners with a URI string; packages/live-resources/src/notifier.ts › ResourceNotifier forwards to sendResourceUpdated({ uri }) and only for URIs present in the subscriptions set, which src/server.ts's resources/subscribe handler can only add to after store.read() succeeds — and only when the canNotify guard, wired in src/server.ts, still finds the current principal holds the scope for that URI's audience. The same question is asked a third time on the way back out of the replay buffer: src/http.ts › MemoryEventStore.replayEventsAfter() runs every stored frame through replayAllowed() before handing it to a resumed stream.
Asserted by. test/server.test.ts — "refuses a subscription to a resource the principal cannot read" (the authorization gate on the way in), "delivers resources/updated to a subscriber when the physio writes", "stops delivering after unsubscribe", and "does not log an internal revision to a user-scoped host" — the last one covering the second channel, where a URI would otherwise escape the partition without any content escaping with it. test/http.test.ts — "withholds an internal URI from a resume whose principal has narrowed" covers the third path, the one a dropped stream reopens.
Residual, rewritten because it closed. This used to read: "the fact that something changed is not re-authorized at send time … the code does not close it and we do not pretend otherwise." That was true and it was a real leak — a principal whose scope narrowed mid-session kept receiving the URI of a resource it could no longer read, which is a change-frequency side channel on a URI whose very existence read() refuses to confirm. It is closed: the notifier takes a canNotify guard and src/server.ts supplies one that re-checks the current principal's scope against the URI's audience at SEND time, mirroring what logRevision() already did on the second channel.
The half of that residual that named MemoryEventStore has since closed too, and it was not theoretical — it was reproducible against a running server, and it defeated canNotify entirely for anyone willing to drop a stream and reconnect. replayEventsAfter() takes a canReplay predicate; createHttpServer supplies one that re-authorizes each stored frame against the principal the resuming request carries, read from the same AsyncLocalStorage the GET handler already populates. Withheld frames are counted (events.replaysWithheld) rather than dropped silently into nothing.
What remains is narrower. The guard reads the principal at the moment the notification is dispatched, or at the moment the resume asks; a scope that narrows between dispatch and delivery is not re-checked, because there is nothing to re-check it against once the frame is on the wire.
I-10 · A prompt cannot emit reasoning-only content, even for a caller that could read it
Statement. prompts/get brief_carer assembles its briefing through a principal narrowed to the speakable scope. A caller holding both scopes still gets a briefing containing no internal content.
Why. This is the one place inside the server where the partition is not simply "the caller does not have the scope". A dual-scope reasoning host legitimately holds care.read.assistant, and a carer handover is exactly the artefact where internal context would leak by accident. Doing it as a narrowed principal rather than a post-hoc filter means there is no code path in the handler that can read a care-internal:// record — the partition is a property of how the text is built, not a check applied to it afterwards.
Where enforced. src/server.ts's GetPromptRequestSchema handler constructs speakableOnly = { sub, scopes: caller.scopes.filter(s => s === SCOPE.user) } and passes it to every store.list() and store.staleness() call in the handler.
Asserted by. test/server.test.ts — "cannot emit assistant-only text even for a caller holding BOTH scopes", which is the only shape of this test worth writing: a dual-scope caller is the adversary here, and a single-scope caller would pass trivially.
I-11 · Pagination is resumable by key, and an unrecognised cursor fails loudly
Statement. resources/list is cursor-paginated at RESOURCE_PAGE_SIZE = 3 over a URI-sorted list. The cursor carries the last URI emitted, not an offset. A cursor the server did not issue produces -32602, not page one.
Why. This server's resource list changes while a client pages — a new domain can appear between two pages and fire list_changed. An offset cursor would silently skip a resource into the gap; a key cursor resumes correctly. Failing loudly on an unrecognised cursor matters because the alternative, resetting to page one, loops a client forever. MCP 2025-11-25 pins none of this: it defines the cursor as opaque and stops. FRICTION.md F-007.
The page size is small on purpose. The demo store holds eight records and resources/list serves nine entries — the eight plus the ui://unsay/echo card (I-16) — so every client walks three pages and the cursor is exercised on the judged path rather than on a code path nobody reaches. test/store.test.ts — "seeds exactly eight records" — pins the first number; the second is that number plus the one card, asserted in test/server.test.ts.
Where enforced. src/server.ts › encodeCursor()/decodeCursor() and the ListResourcesRequestSchema handler. Authorization runs first — store.list(principal) — so a page can never contain, and a cursor can never name, a URI the caller may not see.
Asserted by. test/server.test.ts — "pages at RESOURCE_PAGE_SIZE and the cursor round-trips to the rest", "rejects a cursor it did not issue rather than silently restarting", "never pages an internal uri to a user-scoped principal" (authorization survives pagination, which is where a partition usually springs a leak).
I-12 · A write is authenticated over the raw bytes, before parse
Statement. POST /write accepts either an HMAC-SHA256 signature over timestamp.rawBody with a 300 s freshness window, or a bearer token carrying care.write. Signature verification happens on the bytes as received, before JSON.parse.
Why. Verifying a signature against a re-serialised parse of a body is the classic way to make a signed webhook forgeable: key order, unicode escaping and duplicate keys all survive parse/stringify differently, so the bytes that were signed and the bytes that are stored can be made to differ. Putting the timestamp inside the MAC means a captured request cannot be replayed under a fresh timestamp.
Where enforced. src/http.ts › handleWrite() reads the body with readBody() into a Buffer, verifies, and only then parses. writeSigningMaterial() and signWriteBody() are exported so a client signs exactly what the server verifies. Comparisons use timingSafeEqual behind a length check.
Asserted by. test/http.test.ts — "rejects a body mutated after signing, and audits the attempt" (the exact forgery this design exists to stop), "rejects an unsigned write", "rejects a replayed request whose timestamp is outside the skew window", "rejects a bearer write without care.write", and "accepts a correctly signed write and delivers resources/updated to a subscriber" — the positive case has to be there too, or the negatives could all pass on a route that rejects everything.
I-13 · A client cannot name its own scopes
Statement. Scopes come from a verified token payload and from nowhere else. alg must be HS256, so an unsigned or two-segment token is rejected before any comparison. aud must equal this server's resource identifier (RFC 8707).
Why. Every scope check in src/store.ts is only as strong as the step that decides which Principal a request is. And the aud check is what stops a token minted for another MCP server in the same ecosystem from being spendable here — without it, a compromised sibling service is a route into Ray's record.
Where enforced. src/http.ts › verifyToken(), with TokenError failures typed as malformed | bad_alg | bad_signature | expired | wrong_audience | no_scopes. Bearer tokens are accepted in the Authorization header only (bearer_methods_supported: ["header"]) — a query-string token would end up in access logs.
Asserted by. test/http.test.ts — "rejects an unsigned token — the signature is not optional", "rejects a token signed with the wrong secret", "rejects an expired token", "rejects a token minted for another resource" (the RFC 8707 check), "will not take a scope list from the client — scopes come from the signature", and "401s an unauthenticated MCP request and points at resource_metadata".
I-14 · Every write attempt is attributable, and no audit row can carry attacker bytes
Statement. Accepted and rejected writes both append a row of { at, actor, uri, outcome, reason, via }. actor is the verified principal wherever the request carried one — never a name taken from the body. reason is a closed union.
Why. The rejected row is the one that matters — a stolen or malformed clinician key leaves a trail here and nowhere else. reason is a union rather than free text so that a row has nowhere to put a presented signature, a bearer token, or any other attacker-supplied bytes. That is a stronger guarantee than remembering to redact.
Where enforced. src/audit.ts › AuditLog.append(); no method on AuditLog mutates or removes a row. src/http.ts › handleWrite() appends on every path including body_too_large, which fires before the body is even fully read.
On the bearer path the actor is p.sub from the verified token. It used to be body.authorId, which silently discarded the one identity the request had actually proved: a care.write holder could name any clinician as the author, and the row recorded the impersonated name while the real principal appeared in no row at all. That made this invariant false on precisely the path where it was strongest. The claimed author still reaches the record — see T-4, where one shared write secret means authorId is claimed rather than proven — but the trail now says who presented a credential.
Asserted by. test/http.test.ts — "never records a credential in an audit row", "records the verified token subject, not the authorId in the body", "still lets the claimed author reach the record, which the chain then freezes", and the rejection paths above, each of which asserts the audit row it should have produced.
I-15 · Proof artifacts are reproducible
Statement. seed() produces byte-identical version hashes on every run; the receipts in docs/proof/ are regenerated by the same scripts a judge runs.
Why. A committed receipt that cannot be regenerated is a screenshot.
Where enforced. src/seed.ts derives every timestamp from an injected clock defaulting to the pinned constant DEMO_NOW; there is no Date.now() in the seed path.
Asserted by. test/store.test.ts — "produces identical hashes across runs", "seeds exactly eight records", "seeds the superseded day-1 weight-bearing instruction". npm run seed reseeds twice and exits non-zero if a single version hash differs, and refuses to report success over an empty comparison. scripts/fresh_clone_check.sh clones to a temp directory with empty state and runs the suite, typecheck, seed, probe, verify, e2e, probe:resume, bench and a second e2e with an envelope configured — verbatim, so the invariant holds for a stranger and not only for this working tree.
I-16 · The device card is served through the protocol, not beside it
Statement. ui://unsay/echo is an MCP resource: it appears in resources/list for every authenticated principal and resources/read returns the Echo Show card as renderable HTML under the MCP Apps extension's mime type. It is the same bytes GET /echo.html serves.
Why. The Alexa+ rules name "a basic MCP wrapper around an existing API" as the obvious idea and "media support (cards, carousels), MCP Apps, Agent Skills" as the creative bar. The card already existed and was reachable only over an HTTP static route — which is not an MCP surface at all, and would work identically if this were not an MCP server.
Where enforced. src/ui_resource.ts holds the URI, the mime type, the _meta keys and the single readFileSync of web/echo.html; src/server.ts appends the descriptor in the resources/list handler and short-circuits resources/read before the store is consulted, because ui:// is not a scheme the care partition issues. src/http.ts serves the same file at /echo.html from the same path, so there is one file and nothing to drift.
Asserted by. test/server.test.ts — "is listed alongside the facts, so a host discovers it the usual way", "reads back as renderable HTML under the extension mime type", "carries no assistant-audience content, because Ray can see the card", "binds the fallback tool to the template, so a host knows what to render". scripts/e2e.ts reads it over Streamable HTTP and fails the run if the bytes are not HTML.
Limit, and it is the whole of F-013. No host we can reach implements the extension, so the _meta template binding on whats_changed is shaped and unexercised. The resource is proven; the binding is not. Read it exactly as the KMS provider is read.
I-17 · The retraction is a rendered artifact, not a string in a document
Statement. The spoken retraction is produced by renderRetraction() from the two record versions, and is delivered to the host on _meta['unsay/retraction'] of a resources/read and on each whats_changed entry. Nothing that quotes it writes it by hand.
Why. For a voice device the interaction model IS the sentence, and this one existed as three different literals — in DEMO.md, in web/index.html and in scripts/e2e.ts — generated by nothing and covered by no test. A sentence that no code produces is a sentence that cannot be regression-tested, and the ordering inside it is a safety property: withdraw first, name what is being withdrawn, then the new value with its author and age, then a gloss of the clinical language. Leading with "that changed thirty seconds ago" buries the instruction a frightened person acts on.
Where enforced. src/retraction.ts › renderRetraction(), built from record fields only, with a closed GLOSSARY whose entries restate the clinician and add no guidance. src/server.ts attaches it in the resources/read handler when a previous version exists, and to each whats_changed entry — which is why that tool also returns previousValue and previousVersion: a host on the fallback path never read v1, and without the superseded sentence it can state a fact but cannot withdraw one.
Asserted by. test/retraction.test.ts — "opens with the stop, not with the metadata", "names the sentence being withdrawn, so there is one instruction to drop", "glosses every jargon phrase the new value contains", "adds no guidance of its own — every gloss restates the clinician". test/server.test.ts — "returns the superseded value and the rendered retraction". web/web.test.ts — "matches renderRetraction() for the seeded revision, word for word", which recomputes the landing page's copy and fails if the page and the server ever differ. scripts/e2e.ts prints the server's own output rather than a literal.
Limit. Whether the host says it is the host's decision. MCP settles the notification's delivery and not its consequence (F-003, T-2). We can make the right words available and no more.
Coverage gaps
Every invariant above now has at least one assertion behind it. These are the edges that do not, listed because an unasserted edge is a hope, and hiding the list is worse than the gap.
One gap that used to be here is closed: encryption and the HTTP surface are no longer proven only apart. test/http.test.ts — "names the provider, key, IV and tag of sealed bytes without holding the key" — runs the server over a sealed store and reads the at-rest receipt back off GET /verify, and scripts/fresh_clone_check.sh runs the whole end-to-end sequence a second time with UNSAY_KEY_PROVIDER=local.
| Gap | What is missing |
|---|---|
| I-2 timing | Not measured, and no attempt is made to constant-time the path. -32002 is constant in shape, not in duration. |
| I-9 residual | Closed and asserted on all three paths — test/server.test.ts "does not deliver an internal updated to a downgraded session", the package's "re-checks authorization at SEND time, not only at subscribe time", and test/http.test.ts "withholds an internal URI from a resume whose principal has narrowed" for the Last-Event-ID replay that used to walk around both. What is still unasserted is the narrower window inside a single dispatch: a scope that narrows between dispatch and delivery. |
| Resumability | Last-Event-ID replay is exercised by scripts/probe_resume.ts with a committed receipt (docs/proof/resume.json: stream dropped, three revisions written into the dark, all three replayed in order on reconnect) — a script, not a test, so it is still outside npm test. It is now run by scripts/fresh_clone_check.sh and by scripts/check_submission_readiness.py, which also asserts the committed receipt's verdict. |
| Resumability, the gap under it | A client that has received no event on the standalone SSE stream holds no Last-Event-ID and cannot resume at all — the SDK writes no priming event on that stream (FRICTION F-010). A correction published before the first event on a fresh stream is stored and not replayable. Unfixable from this side; disclosed rather than closed. |
| Key rotation | Envelope.openAsync() unwraps whatever key the stored bytes name, and test/envelope.test.ts covers it at the provider level. No test rotates a live store's key and reads old records back through it. |
| KMS | KmsKeyProvider has never been executed against a live CMK — the account is under the hold logged as F-004. Its SigV4 canonical request, signed-header list, endpoint and target are asserted; that AWS accepts them is not. |
| MCP Apps binding | The ui://unsay/echo resource is served, listed and read over the wire by npm run e2e. The _meta template binding that tells a host to render a tool result INTO it has never been honoured by a host, because no host we can reach implements the extension. F-013. |
| The browser host | web/web.test.ts now slices echo.html's own MCP client out of the page and runs it against a live server — initialize, pagination, subscribe, the SSE framer, the correction. What is still unverified is rendering: layout, animation timing and the 1280×800 device fit are checked by eye, not by a headless browser. |
The suite is test/store.test.ts, test/envelope.test.ts, test/server.test.ts, test/http.test.ts, test/retraction.test.ts, test/docs.test.ts, web/web.test.ts and packages/live-resources/test/live-resources.test.ts. npm test prints the exact count and the README headlines it; this document deliberately does not repeat the number, because a count copied into a second place is a count that goes stale. What matters here is which assertion backs which invariant, and that is named next to each one above.
Threat model
The asset is a partition: facts about one patient, some speakable to him and some not, all of which change without warning.
T-1 · A compromised or non-compliant MCP client that ignores annotations.audience
Capability. Reads everything it is served, ignores every annotation, speaks anything it receives.
Mitigated. Fully, for leakage. A user-scoped client is never sent internal content, never sees an internal URI in resources/list, cannot page one into view, cannot discover one by probing, and is not told over the log channel that one changed (I-1, I-2, I-4, I-11). It cannot leak what it does not hold. The annotation being advisory (F-002) and being dropped on reads (F-005) is precisely why the design never relies on it.
Residual. It can still speak a care:// fact while ignoring the [STALE — …] header. We can refuse to give a client what it must not say; we cannot make it say what it must.
T-2 · A client that never subscribes
Capability. Fully compliant, but does not implement resources/subscribe, or implements it and never re-reads on updated — which MCP 2025-11-25 permits, since it specifies the notification's delivery and not its consequence (F-003).
Mitigated. Partially, by paths built from the first commit rather than bolted on: the whats_changed tool with an outputSchema returning every revision since a timestamp with its age and author; the server instructions field telling the host in plain language to call it before answering any care-plan question; and a second notifications/message channel carrying the same revision facts for hosts that surface logs. scripts/e2e.ts fails the run if the tool does not return exactly the one revision just made — an unexercised fallback is indistinguishable from a missing one.
Residual. Nothing compels a host to call the tool or read instructions. This is the one risk in the design that no amount of server engineering removes.
T-3 · An attacker with write access to storage
Capability. Rewrites stored records directly, bypassing publish() and therefore bypassing I-3; or relocates a ciphertext from one record to another.
Mitigated. Relocation is defeated by AAD binding (I-6) and proven by "DEFEATS a cross-record paste in storage itself". Corruption is detected by the GCM tag at read and by chain replay at verify() (I-5). With no envelope configured, detection only — and startupLine() prints the word PLAINTEXT so nobody can be confused about which mode is running.
Not mitigated. An attacker holding storage and the data key can re-seal under the correct AAD and recompute every downstream hash; the chain will verify clean. What defeats that is a signature over the chain head from a key the attacker does not hold, and there is none. Neither is there durable storage: the store is an in-process Map, so "write access to storage" today means "code execution in the process", which is a larger compromise than this row describes.
T-4 · A forged clinician write
Capability. Posts a plausible revision — "full weight-bearing as tolerated" — that an assistant then speaks to a post-operative patient with a named clinician's authority. The highest-consequence attack on the product.
Mitigated. HMAC-SHA256 over timestamp.rawBody verified before parse, constant-time comparison, a 300 s freshness window, and writtenAt taken from the server clock so a write cannot be backdated (I-12, I-7). Rejections are audited with a typed reason and never carry the presented signature (I-14). The alternative writer path — a bearer token — must carry the care.write scope, which is advertised in the protected-resource metadata and enforced.
Residual, stated plainly.
- No nonce cache. A captured signed write is replayable for up to 300 s. The window is bounded and named; it is not zero.
- A live stolen key writes valid updates, exactly as in any signed-webhook system. Unmitigated by design.
- One shared write secret, from
UNSAY_WRITE_SECRET, not per-author keys.authorIdis therefore claimed in the body, not proven by the signature: the signature proves a holder of the write secret wrote this, not which clinician. The hash chain then makes that claimed authorship immutable — immutable, not authentic. On the bearer path an identity IS proved, and the audit row records it (I-14); the record still carries the claimed author, so the two can disagree, and a row where they disagree is the row an investigator wants. - Defaults exist.
DEV_WRITE_SECRETandDEV_TOKEN_SECRETare used when the environment variables are unset, and the server announces it at startup when it is running on the default token secret. A deployment that ignores that line is unauthenticated in practice.
T-5 · A token with the wrong audience, or a lifted token
Capability. Presents a token issued for a different resource server, or replays one captured from a legitimate host.
Mitigated. verifyToken() requires alg: HS256 (so alg: none and two-segment tokens die before any comparison), a valid signature, an unexpired exp, an aud equal to this server's resource identifier (RFC 8707), and a non-empty scope list with a sub (I-13). Scopes are read from the signed payload and can never be asserted by a client. 401 responses carry a WWW-Authenticate header naming resource_metadata, and /.well-known/oauth-protected-resource is served at both the bare path and the /mcp-suffixed path RFC 9728 specifies.
Residual.
- There is no separate authorization server. Tokens are HS256 under a shared secret and are minted by
mintToken()in the same file that verifies them. No JWKS, no asymmetric keys, no token endpoint, no dynamic client registration, no consent. This is a resource server implementing verification correctly against tokens it also issues — which is honest for a hackathon build and is not an OAuth deployment. - No replay cache. A
jtiis minted and never checked, so a lifted token is spendable untilexp(default one hour). - No revocation. I-9's residual follows from this.
T-6 · Existence probing
Capability. A patient-facing token enumerates domains to learn what clinical concerns exist, without reading any.
Mitigated. I-2, extended to every channel: read(), list(), pagination, the public /verify route, and the revision log.
T-7 · The confused deputy — a host that legitimately holds both scopes
Capability. The reasoning host holds care.read.user and care.read.assistant, because that is the point: internal facts exist to shape the answer. Having read care-internal://ray/risk, it paraphrases "family disputes the discharge plan" into speech.
This is the residual risk of the entire design. Concretely, in this repo: scripts/e2e.ts grants its alexa-host principal both scopes. The partition, as demonstrated, protects Ray against a user-scoped client — the patient-facing surface, a family member's app, a logging sidecar, a second host — and does not protect him against the reasoning host itself choosing to speak.
Partially mitigated in one place, and only one. prompts/get brief_carer narrows the caller's principal to the speakable scope before assembling anything (I-10), so the one server-generated artefact intended for a human other than the model cannot contain internal content even for a dual-scope caller. That is the shape a fuller mitigation would take, applied to a single surface.
What would actually close it is an architecture where the speech surface is a different principal from the reasoning surface, holding only care.read.user, with the reasoning host forbidden from emitting text directly. That is a host-side property. MCP gives a server no way to require it.
We say this out loud because a judge will open scripts/e2e.ts and ask.
What Unsay does not defend against
A threat model with no out-of-scope section is marketing.
- A malicious host holding
care.read.assistant. T-7. Out of reach from the server. - A compromised clinician credential. T-4.
- Model behaviour. There is no output filter. Nothing inspects what the assistant is about to say for traces of internal content — and adding one would be a second, weaker copy of a boundary already enforced at the source.
- Timing and traffic analysis.
-32002is constant in shape, not in time. Notification timing reveals that a resource changed to anyone who can observe the stream (I-9). - Denial of service. A 256 KiB body cap on
/writeand a bounded event store, and that is all: no rate limiting, no quotas, no backpressure on subscriptions, no connection limits. - Key management. No rotation policy, no revocation, no HSM story. The envelope has a rotation path (
openAsync()unwraps whatever key the bytes name) and no rotation process. - Multi-tenancy. The store is keyed
patient/domainand every principal is scoped to the whole store. There is no per-patient authorization; a second patient would need one and it does not exist. - Transport security below TLS. Assumed, not provided. Nothing in this repo terminates TLS.
- The clinical content itself. Unsay relays what a clinician wrote. It does not generate, check, or reason about medical guidance, and it is not a medical device.
- Anyone within earshot. The Echo Show is in a bedroom. The audience partition is a partition of facts, not of listeners — a daughter in the doorway hears whatever is spoken. That is a real limit of a voice-first product and no server-side control touches it.
Failure modes
The host does not support resources/subscribe
Nothing errors. The server declares resources: { subscribe: true, listChanged: true } regardless; a host that never sends resources/subscribe simply never enters the subscriptions set and sendResourceUpdated is never called for it. Corrections remain reachable through whats_changed and the log channel.
The failure is silent by construction, which is why scripts/probe_subscribe.ts exists and why it records the negotiated capability block into docs/proof/probe_subscribe.json rather than asserting it. A capability a host does not have should be a recorded fact, not a crash.
The notification arrives after the assistant has finished speaking
Then nothing retracts, and Ray acts on the old instruction. The product has no mechanism to interrupt speech that has already ended and does not claim one.
What bounds the risk is a measured number. scripts/bench.ts measures clinician-write → client-holds-new-value end to end over Streamable HTTP, and npm run bench -- --n 200 regenerates docs/proof/bench.txt and docs/proof/bench.json, which are the authoritative copies. The result this document depends on is the pass/fail one — every run lands inside the speech window — and bench.ts exits non-zero if even one revision does not. The p50/p95/max figures are deliberately not repeated here: they change on every run, and a latency quoted in two places is a latency that will be wrong in one of them.
Three honesties about that number:
- Loopback. Both processes are on one machine. The script prints this and refuses to let the figure be quoted as production.
- The window is an assumption. 3400 ms is 12 words at ~150 wpm, a constant in
scripts/bench.ts. It is not a measurement of Alexa+ speech synthesis, and a shorter real utterance shrinks the margin. - The measurement stops at the client. It ends when the MCP client holds the new value. It does not measure the host deciding to re-read, the model deciding to retract, or TTS producing the retracting audio. Those belong to the host and are not ours to measure.
The notification arrives and the host does not re-read
MCP specifies delivery, not consequence (F-003). The fallback is whats_changed; the honest position is T-2.
The stream drops mid-answer
src/http.ts › MemoryEventStore stores each notification whether or not a stream is attached and replays it — to a principal that still holds the scope for the URI it names, checked again at replay time — on Last-Event-ID, so a correction survives the network dropping under an Echo Show mid-sentence. scripts/probe_resume.ts proves it end to end and commits the receipt: a revision is delivered live, the stream is dropped, three further revisions are written while it is down, the client reconnects with Last-Event-ID, and all three are replayed in order (docs/proof/resume.json). Three and not one on purpose — a replay that delivered only the LAST missed notification would pass a single-revision probe and lose the middle of a correction sequence in the field. The store is in-memory and capacity-bounded at 2048 events: a long enough outage, or a process restart, loses the replay.
A scope is revoked between subscribe and revision
Closed, and this paragraph is rewritten rather than deleted because it was wrong twice over. It used to end "not exploitable in this build, where scopes are fixed per session". Scopes are not fixed per session: src/http.ts re-reads the principal on every request and updates session.principal, which is the whole reason canNotify exists. And when the live path was guarded, the replay path was not — resuming with Last-Event-ID under a narrowed token of the same subject handed back the internal URI, its version, its author and its hash-chain links. Both are now re-authorized at send and at replay (I-2, I-9), content was protected throughout by read(), and the residual is the dispatch-to-delivery window, which nothing can re-check.
The process restarts
Everything is lost. LiveResourceStore is an in-process Map, the audit log's durable half is an optional JSONL sink, and the event store is memory. A restart re-seeds and the chain history is gone. Durable storage is not built.
A version or a cursor is requested that does not exist
A missing version gives -32002, like everything else (I-2). parseUri() rejects a malformed version segment — care://ray/weight_bearing/3 returns null rather than being read as v3, so a typo fails closed instead of silently serving the latest. A cursor the server did not issue gives -32602 (I-11).
What Unsay does not claim
- Not medical advice. Unsay relays what a named clinician wrote, with its age and its author. It generates no guidance and makes no clinical decision.
- Not a guarantee that the correction is spoken. It guarantees the correction is available, provably, within a measured latency. The last step belongs to the host (T-2, F-003).
- Not protection against the reasoning host itself (T-7).
- Not a production latency figure. The bench is loopback and says so in its own output.
- Not an OAuth deployment. A correctly-implemented resource server with a symmetric, self-issued token (T-5).
- Not per-clinician write authenticity. One shared write secret (T-4).
- Not durable. In-process state, no persistence.
Not built
ARCHITECTURE.md is generated by scripts/gen_architecture.ts from actual handler registrations and package.json, precisely so it cannot drift into fiction. Read it for the authoritative list of what exists. As of this writing the repo has no DynamoDB, Lambda, KMS-backed deployment (the KMS client is real; no AWS resources are provisioned by this repo), no persistence layer, no authorization server, no rate limiting, and no ML model of its own — the reasoning model belongs to the host, by design.
This repo is under active development by several concurrent workstreams. Where a mechanism above is implemented but not yet asserted, it is in the gap table rather than described as proven. When an assertion lands, its row leaves that table and the corresponding residual-risk paragraph must be rewritten, not quietly deleted.
Rendered from docs/SPEC.md in the repository.
Source as text: /docs/SPEC.md.