Skip to content

feat: Concierge 0.4 contract v4 and five-package set - #10

Merged
LakshmanTurlapati merged 8 commits into
mainfrom
gsd-quick-updatesgsd-quick-updates
Sep 21, 2026
Merged

LakshmanTurlapati merged 8 commits into
mainfrom
gsd-quick-updatesgsd-quick-updates

Conversation

@LakshmanTurlapati

Copy link
Copy Markdown
Contributor

Summary

  • Bump the runtime contract to v4: consent binds handler-proposed payloads (propose / represent / attestReadback), catalog acknowledgement defers setContext, and vacuous snapshots fail closed.
  • Land the rest of the 0.4 surface in the same publish: dispatch observability, turn/rendition coordinators, awaitRegistration, resolveValue, catalog prompts, @full-self-browsing/concierge/testing, plus @full-self-browsing/concierge-dom and @full-self-browsing/concierge-realtime.
  • Update React/Svelte adapters (useConciergeActivity last event, nullable useConciergeBridge) and ship the 0.3→0.4 migration, realtime, and bridge-registration docs.

Package versions stay 0.3.0 in source; Changesets publishes the five-package group as 0.4.0.

Test plan

  • pnpm build
  • pnpm typecheck
  • pnpm test (677 passed)
  • node scripts/release/check.mjs source
  • CI on this PR
  • After Changesets version: node scripts/release/check.mjs all

Acceptance boxes from the 0.4 handoff:

  • CONTRACT_VERSION === 4 in core and both adapter guards
  • docs/migrations/0.3-to-0.4.md exists and is linked from COMPATIBILITY.md
  • Consent binds ctx.review.propose payloads; presented is in the hash; vacuous snapshots fail closed
  • Catalog ack does not promote context until accepted; stale/duplicate/stopped acks are ignored
  • Delivery still requires responseId equality; delivered can arm without a delivery hook
  • redactMessage default is passthrough; ./testing does not bundle a second core
  • DOM never finds elements and has no .focus()
  • Realtime consumes the core OpenAI codec; WebRTC CI gap is documented and covered by a fake-peer unit suite
  • Changesets fixed group is the five public packages

Made with Cursor

LakshmanTurlapati and others added 8 commits September 16, 2026 11:16
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.
@LakshmanTurlapati
LakshmanTurlapati merged commit c9a2279 into main Sep 21, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant