feat: Concierge 0.4 contract v4 and five-package set - #10
Merged
Merged
Conversation
Bind consent to handler-proposed payloads, add catalog acknowledgement, and land the DOM, realtime, testing, and adapter surfaces in one 0.4 release. Co-authored-by: Cursor <cursoragent@cursor.com>
ref(key) held one release slot for a key that holds a set of registrations, so a second node mounted under the same key silently unregistered the first -- the responsive case concierge-dom's README says the package exists to solve. Track a release per element and return it as a cleanup so React 19 releases exactly the node it attached; React 18's element-less ref(null) prefers disconnected nodes and falls back to newest-first. sanitizeText's ellipsis branch shortened an already surrogate-safe slice by one code unit, stranding a high surrogate whenever the slice ended on an astral character. readUntrusted is the only caller passing ellipsis, and the malformed string then failed prepareReadback with payload_unsupported. Both cuts now go through one helper. waitForFrame closed over two const cancellers declared below finish, so an injected frame or scheduler that fires synchronously reached them in the temporal dead zone and reveal rejected with a ReferenceError instead of returning an outcome. attestationTurnAfter ran its loop to completion and returned the last attested turn, letting a confirmation arbitrarily far in the future stand in for the one the person took in answer to the review.
catalogDerivedPolicy reported continuationSensitiveNames: [] when no
projection was remembered for a revision -- a catalog built by another
core instance, or one that never passed through
rememberCatalogProjection. That reads as restraint and is the opposite:
a consumer using the field to decide whether to continue past an action
would have treated every destructive and consent-gated action as safe.
Unknown sensitivity is now reported as sensitive, matching the "drop"
redaction default beside it.
onCatalogAcknowledged had to be read off the transport before calling
it, because the typeof guard is what rejects a transport that claims
acknowledgesCatalog without implementing it -- but the call then went
out unbound, so a method-shorthand subscriber touching `this` would
throw. It goes back through Reflect.apply, as publish already does.
snapshotAttestation read act, actId and readbackHash once to validate
and again to build the frozen copy, so an accessor-backed attestation
could pass validIdentifier and hand a different actId to the record. It
now reads every field once through host.ts's own-data helpers, so no
getter runs at all.
attestReadback matched on readbackHash alone and took the first hit in
Map insertion order. The hash covers {payload, presented} and not the
action, so two reviews with byte-identical readbacks were conflated and
the kernel could arm one the person had not heard. Two matches now
refuse.
preferredScrollBehavior returned "smooth" when matchMedia was absent.
It governs every reveal() and scrollViewport() that omits behavior, so
a host that cannot report the reduced-motion preference must not be
animated on the assumption it is fine.
Three structures grew with session length in packages whose whole point is a long-lived voice session. The realtime delivery ledger kept every settled group in the array findOpenGroup, observeAttestation and revokeAll scan, so the scan cost grew with the session's history; settled groups now leave it, which is behaviour-neutral because every reader already skipped them. hashesByOrigin and turnsByOrigin outlive the group that consumed them and cannot simply be deleted, so they are bounded instead, reusing the eviction shape createRealtimeTurnLedger already had for byResponse. The rendition binder's settled-cause memory and core's attestation actId set are likewise bounded. The actId bound carries its reasoning at the declaration, because capping a replay set usually is not safe: this one is defence in depth behind the generation-identity check, the already-attested status check, and closeConsentGeneration, any of which refuses a replay on its own. duplicate_settlement was declared in RenditionIssueCode with no producer, while the situation it names reported unbound_rendition -- the first settlement deletes the bindings, so a second one looked exactly like a rendition that never had a cause and sent a reader after a binding bug that does not exist. awaitRegistration carried a cancel-when-available flag that was never set. Every path that settles before the timer is armed returns before reaching the scheduler, so no handle ever owes cancellation there. resolveValue re-scanned its candidates for a rival sharing the queried label inside the branch where the filter had already produced exactly one row, so the branch could never fire. Real rivalry is decided one arm below.
…ey are RELEASING.md was still the 0.3 document -- a trio, .release/lines/0.3.json, contract v3 -- while CONTRIBUTING.md and .release/lines/0.4.json declare the five-package v4 set, and CONTRIBUTING points contributors here for the ceremony. It now names the five packages in publish order and records where the set is enforced. It also records what the compatibility gate does not cover. scripts/release/compatibility.mjs installs core, React, and Svelte archives into consumer cells; concierge-dom and concierge-realtime are in the release set but have no cell, so their certification is package.mjs, attw, publint, and the seal. Silence there is a gap, not a pass. buildCatalog's snapshotSources walk reads registry.read(), so a catalog built at module scope inspects nothing and reports nothing. That reads as a clean bill of health and is not one. The JSDoc and migration guide now say so, and say that the runtime consent_stale refusal is the gate that holds without the rebuild. The concierge-dom README documents the ref cleanup protocol and what the element-less React 18 detach can and cannot recover.
Core reads DeliveryReport.readbackHash in exactly one place — to substantiate a claim to `attested` — and answers a claim it cannot substantiate by closing the consent generation. Three separate paths handed it claims that were never claims. The realtime attestation hold settled with the hash and no attestation when it elapsed, so an unanswered readback revoked consent instead of resting at `relayed`, and a person confirming a moment later got `unknown_readback`. Since `attested` is unreachable without attestationWindowMs, that was the whole attested realtime path. The hash now travels with the attestation or not at all. snapshotAttestation required an own-data `userTurnId` that ReadbackAttestation declares optional, so a report that typechecks failed the snapshot outright — indistinguishable from a hostile one, and answered the same way. Absent is now accepted and the refusal stays with validConfirm, which already demands a non-empty confirming turn distinct from the review's; a `declined` act without one is recorded as the human decision it is rather than erased. A session that reconnected while a publication was unacknowledged republished `currentCatalog`, which is null before the first acknowledgement — nothing was re-sent and the session never promoted a catalog at all. After it, re-sending the promoted revision drew an acknowledgement that failed against the pending head. Reconnect now re-sends the revision the transport still owes. Also bounds the rendition binder's `started` set, the last structure in that file still growing with the session: settle dropped an id only once it had a bound cause, so every playback with nothing deferred against it left its string behind.
CI has failed on every 0.4 commit, dying in the first step before build, typecheck or test ever ran. Three gates were still written for the 0.3 trio. version.mjs spelled its self-test fixture as a literal `^0.2.1 || ^0.3.0` and checked it against `config.releaseLine`, so moving the line to 0.4 made the check demand a 0.3 target from a 0.4 config and fail — a gate reporting its own staleness as a policy violation. The fixture now derives from the live line, and a transition targeting a foreign line is asserted to be rejected. package.mjs asserted `new Set(names).size === 3`, so adding the fourth and fifth packages made a uniqueness check fail for a set that is unique. It now counts the release line, and the identity assertion names the last package in the published order. The runtime job required `CONTRACT_VERSION === 3`, which contract v4 could never satisfy. It now requires 4 across core, ai-sdk and dom, and imports concierge-dom and all three concierge-realtime subpaths — both packages ship in the fixed set and nothing installed them into a consumer before.
`2ffb743` declared the 0.4 line but never touched the release workflow, whose last commit is the 0.3 release. Everything here follows from that. The publish launcher required `contractVersion === 3`, so a v4 seal could only ever throw. Its pinned tool digests were stale for `config.mjs` and named the retired `0.3.json` for a sealed file that `seal.mjs` fills from the live line — a split-brain `check.mjs` shared, digesting 0.3 while the seal carried 0.4, so the gate could pass while publish was guaranteed to fail. `check.mjs` now digests `config.path`, and all three pins are recomputed. `compatibility.mjs` probed the whole release set inside the Next example while injecting only three of it, so the topology probe threw "Cannot find module" before reaching an assertion — latent, because CI has never got that far. The probe now reads the same list that builds the manifest. The framework cells carry all five archives, which is what retires the certification gap `RELEASING.md` disclosed: dom and realtime are imported from their exact archives with no DOM present, typecheck under `skipLibCheck: false`, and prove one physical core. That gate immediately caught a missing required field in the realtime consumer probe. `checkContractV4` guarded four of the five packages; realtime declared the same guard unchecked. Versions move to 0.4.0 through `version.mjs apply`. The bounded core-peer transition `RELEASING.md` prescribes for a pre-1.0 minor is required, not optional: without it Changesets reads the new core as out of range for every dependent peer and bumps the set to 1.0.0. `artifact.test.ts` pinned `^0\.3\.\d+$`, turning the first correct 0.4 manifest into a failure; it now reads the live release line. Consumer-facing documentation was two lines behind: SECURITY.md told installers to verify four packages at 0.2.x, and core's README — which ships inside the tarball — claimed contract 3 and linked only the 0.2→0.3 migration. The root README never mentioned the two packages being published for the first time.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
propose/represent/attestReadback), catalog acknowledgement deferssetContext, and vacuous snapshots fail closed.awaitRegistration,resolveValue, catalog prompts,@full-self-browsing/concierge/testing, plus@full-self-browsing/concierge-domand@full-self-browsing/concierge-realtime.useConciergeActivitylast event, nullableuseConciergeBridge) and ship the 0.3→0.4 migration, realtime, and bridge-registration docs.Package versions stay
0.3.0in source; Changesets publishes the five-package group as0.4.0.Test plan
pnpm buildpnpm typecheckpnpm test(677 passed)node scripts/release/check.mjs sourcenode scripts/release/check.mjs allAcceptance boxes from the 0.4 handoff:
CONTRACT_VERSION === 4in core and both adapter guardsdocs/migrations/0.3-to-0.4.mdexists and is linked fromCOMPATIBILITY.mdctx.review.proposepayloads;presentedis in the hash; vacuous snapshots fail closedresponseIdequality;deliveredcan arm without a delivery hookredactMessagedefault is passthrough;./testingdoes not bundle a second core.focus()Made with Cursor