fix(discovery): stop advertising a realtime service with no mounted surface, and define a subscribable channel once - #15978
Conversation
…h discovery producers Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…ion; docs + changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…nce docs Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 4 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a6c3e5a9d76f9c9323c8bba2aac91a7ac347c067 && git checkout a6c3e5a9d76f9c9323c8bba2aac91a7ac347c067
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f7db8f4fd268a86a08c62ae4894cf7417720f8c9 3c22dddf2ec9e7233cfd5de8c7d68562cab28bf1 && git checkout -B drift-repro f7db8f4fd268a86a08c62ae4894cf7417720f8c9 && git merge --no-ff 3c22dddf2ec9e7233cfd5de8c7d68562cab28bf1
node scripts/docs-audit/affected-docs.mjs --json f7db8f4fd268a86a08c62ae4894cf7417720f8c9
|
…alsified Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…e test's cross-package source dep The dogfood census pins per-file control counts exactly; the comment added to realtime-service-plugin.ts moved `RealtimeService` 10 -> 11 because the pattern matches inside `IRealtimeService`. Re-measured in the ledger rather than reworded in the file. The runtime pin's import of the real InMemoryRealtimeAdapter made @objectstack/runtime type-resolve @objectstack/service-realtime through dist. Measured against origin/main: this package's typecheck script already named tsconfig.test.json, so the program was already counted and the registry's re-baseline limb does not apply; the mandated `paths` remedy then billed 13 TS6059 from another package's file graph into this package's ledger, against a rootDir tsconfig.test.json states it will not widen. Edge removed instead. The claim about the shipped occupant stays pinned against the real class in service-realtime's own suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Measured, one mutation and two pins: giving the shipped InMemoryRealtimeAdapter a getChannelRoute() reddens service-realtime's no-channel-route pin (2 of 3 cases) and leaves the runtime producer pin green on all 4. So the shipped- occupant claim is covered next door, not lost — and the runtime pin's stand-in is equivalent to the real adapter only on the two reads a producer performs, which was measured rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
|
Standing down on the red
Mechanism (measured on #15980, filed as #15990): the self-test writes a stub New datum from today, worth recording here: the failure is flaky per job, not per PR. #15980's re-run reproduced it byte-identically; #15996's re-run cleared it. So this is not a property of any diff — it is whether the launcher resolves in that particular job. Severity bound: false-RED only, never false-green — a failing launcher makes every artifact look No fix to port: #15990 is dispatched and in flight; no patch exists yet to carry into this PR. ⛔ No test skipped, disabled or quarantined; ⛔ no empty commit. Spending this PR's one permitted re-run, which is unspent. Unrelated to the park: this PR remains Generated by Claude Code |
|
Correction to this seat's own comment above: the "re-run cleared it" rows were WRONG, and the mechanism I gave was wrong too. 1. No re-run cleared anything. I reported #15996's and #15978's re-runs as having cleared the failure. They had not. Measured just now, their 2. The mechanism I described — "an ambient
That last point is what my "flaky per job" story could not explain and this one does: flaky ACROSS jobs, byte-identical WITHIN one. 3. The standing-down conclusion is unchanged and is now better supported: this failure is not this PR's, it can only false-RED, and #16009 takes the launcher dependency out of the fixture. What changes is that my evidence for it was partly wrong, and the corrected evidence is stronger. Generated by Claude Code |
Closes #14646
Maintainer ruling A (director summon #14, 2026-09-04): realtime stays out of open core,
/discoverystops advertising a realtime service that has no mounted surface, and "what counts as a subscribable channel" becomes one explicit definition. No realtime surface is implemented here.The defect was a field with two meanings, not a wrong value
On a stock boot
/api/v1/discoveryreported the realtime slot asenabled: trueand, in the same entry,"In-process event bus only — no HTTP/WS realtime surface is mounted", with noroutes.realtime. Both statements were true, becauseenabledmeant "the slot is filled" — which for an in-process pub/sub bus says nothing about whether anything is listening on the wire. A client reading it as "a channel exists" subscribed to nothing and lost the feature silently: no error, no failed request, no signal at all.So flipping the boolean would have satisfied clause 2 and missed clause 3, leaving the next half-mounted service to reproduce the bug.
The definition
It is written and computed in exactly one place —
isSubscribableChannel()in@objectstack/spec/api— and it is enforced rather than documented: both discovery producers setservices.realtime.enabledandcapabilities.websocketsto the value of that call, so the field a consumer reads and the predicate a consumer is told to use are the same computation and cannot disagree.capabilities.websocketswas previously a literalfalsein each producer. Two constants that happen to agree are not agreement; they are two places to forget.Where it applies, and where it deliberately does not
The predicate is applied per slot, to the slots whose advertised capability is a channel —
CHANNEL_SURFACE_SLOTS,realtimealone.cache,queueandjobdeliver their whole contract in-process, so they stay honestlyenabled: truewith no route; makingenabledmean "a route exists" globally would have retracted three working services.realtimekeepsstatus: 'degraded'and its message, so a consumer can still tell "registered but no wire" from "not installed".The route comes from the occupant (
IRealtimeService.getChannelRoute(), new and optional), because no producer in the open framework mounts a realtime transport and neither builder can honestly invent one. Deliberately about the channel and not about a handshake: SSE mounts a plain GET and never upgrades, so gating onhandleUpgradewould have refused a legitimate transport.@objectstack/service-realtimedoes not implement it, and a pin says so.What changed
packages/spec/src/api/discovery.zod.tsisSubscribableChannel(),readChannelRoute(),CHANNEL_SURFACE_SLOTS;enabled/websockets/routes.realtimedescriptions say what they do and do not meanpackages/spec/src/contracts/realtime-service.tsIRealtimeService.getChannelRoute()— the producer halfpackages/metadata-protocol/src/protocol.tsgetDiscovery()derives the realtime entry,routes.realtimeandcapabilities.websocketsfrom the predicate; the routes loop reads the built entry instead of re-deriving from the tablepackages/runtime/src/http-dispatcher.tsgetDiscoveryInfo();routes.realtimeis no longer a hardcodedundefinedpackages/services/service-realtime/src/realtime-service-plugin.tsprotocol/kernel/realtime-protocol.mdxcarries the definition and a do-not-key-on-enabledcallout;kernel/services-checklist.mdxrows updated;content/docs/references/**regeneratedRed first, then green — both directions
Ablation: both producer files restored to the merge base
d4c2cb196(fix committed first; restore trap with absolute paths; marker counts proved the mutation landed —isSubscribableChannel5 → 0 inprotocol.ts, 4 → 0 inhttp-dispatcher.ts; restore verified by blob hash and an emptygit diff HEAD).RED,
metadata-protocol/src/discovery-realtime-channel.pin.test.ts— exit 1, 3 failed / 2 passed:does NOT advertise an in-process realtime bus as a channel—expected true to be falseDOES advertise a realtime occupant that mounts a channel—expected 'degraded' to be 'available'answers enabled, routes.realtime and capabilities.websockets with the SAME predicate—services.realtime.enabled: expected true to be falseRED,
runtime/src/discovery-realtime-channel.pin.test.ts— exit 1, 3 failed / 1 passed, the same three assertions.GREEN on this branch: both files pass. The positive pin is kept deliberately: without it the fix would be indistinguishable from "never advertise realtime", which is another hardcode rather than a definition.⚠️ The producer pins drive a declared stand-in, not the shipped
InMemoryRealtimeAdapter— see "Two CI failures" below for why, what it cost, and where that claim is pinned instead.Verification
Suites and typechecks run at
ff617529c; the ratchet-class gates, the two CI repairs below and their pins were re-run at the final head3c22dddf2. Exit codes captured before any pipe.pnpm --filter @objectstack/metadata-protocol exec vitest run src/discovery-realtime-channel.pin.test.ts src/discovery-schema-conformance.test.tspnpm --filter @objectstack/runtime exec vitest run src/discovery-realtime-channel.pin.test.ts src/http-dispatcher.test.ts src/discovery-schema-conformance.test.tspnpm --filter @objectstack/metadata-protocol test(full package)pnpm --filter @objectstack/service-realtime test(full package)pnpm --filter @objectstack/objectql test(full package)pnpm --filter @objectstack/runtime test(full package)pnpm --filter @objectstack/dogfood exec vitest run test/authz-probe-blind-spot.test.tsnode scripts/check-type-source-resolution.mjs(+--self-test)pnpm --filter @objectstack/rest exec vitest runover the fivediscovery-*suites, against a rebuiltmetadata-protocoldistpnpm --filter PKG typecheckfor@objectstack/spec,@objectstack/metadata-protocol,@objectstack/runtime,@objectstack/objectql,@objectstack/service-realtimeruntimered once on twoTS2339in the new pin file, fixed by annotating the dispatcher's realtime entryServiceInfo, then 0)pnpm --filter @objectstack/spec run check:generatedapi-surface/,export-origins/,content/docs/references/**check:api-surface·check:authorable-surface·check:export-origins·check:exported-any·check:dual-source-exports·check:entry-nameability· speccheck:docscheck:nul-bytes·check:cross-package-test-inputs·check:test-source-alias·check:service-providers·check:merge-drivercheck:doc-anchors·check:doc-authoring·check:docs-single-h1·check:docs-redirects·check:docs-audit-scope·check-doc-frontmatter·check-docs-section-namecheck-empty-changeset --base origin/main·check:objectui-changesetLint is CI's repo-wide run; the narrowing here is measured rather than assumed.
pnpm lintiseslint . --no-inline-configover every file the one rooteslint.config.mjsdeclares — it registers no.mdx/.md/.jsonhandling, so the lintable intersection of this diff is exactly its.ts/.mjspaths.npx eslint --no-inline-config --format jsonon those reported 11 files linted, 0 errors, 0 warnings, exit 0 (captured before any pipe). Untouched files cannot have moved: that config's own header records, with a positive control, that it "never enables type-aware linting (noparserOptions.project, no typed@typescript-eslintrules) for ANY file".api-surfacedelta: 3 added, 0 removed / narrowed (CHANNEL_SURFACE_SLOTS,isSubscribableChannel,readChannelRoute).Gate family derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackfrom the real change set (16 paths vs merge base), not from a hand-written list. The repo-wide scans (pnpm lint, the full 55+ gate farm) are CI's run.Published-docs drift — per-page verdicts
Six hand-written pages were flagged. Each was read per file at a known clean sha (
ff617529c,git status --porcelainempty), not by a corpus-wide grep; every zero-hit claim below was taken from a command whose positive control fired on the same file and scope.content/docs/protocol/kernel/realtime-protocol.mdx"enabled": trueforrealtimeand promised "discovery will advertiseroutes.realtimeagain". Rewritten: the payload now showsenabled: falsepluscapabilities.websockets, and a new "What counts as a subscribable channel" section carries the definition and a do-not-key-on-enabledcallout. Re-scanned after the edit: every remainingadvertis/discovery/enabled: truehit on the page (7) is inside that rewritten block; controlsubscribe= 51 hits. The rest of the page documents a planned transport and stays true because nothing was mounted.content/docs/kernel/services-checklist.mdxdegradedwhileroutes.realtimeis "never advertised"; (2) the closing capability bullet said "websocketsis hardcodedfalse" — it is now derived, so that sentence is simply wrong; (3) the general rule "theservicesmap still reports a registered stub as{ enabled: true, … }" now has exactly one exception, since a registered realtime stub readsenabled: false. All three rewritten, the exception declared rather than left implicit.hardcodedno longer appears on the page.content/docs/ai/knowledge-rag.mdxIRealtimeServiceevents driveKnowledgeService.handleRecordUpsert/Delete. That is in-processpublish/subscribefan-out; this change adds one optional member and alters neither delivery nor how discovery describes in-process delivery. No edit.content/docs/automation/webhooks.mdxIRealtimeService.publish(event)fan-out andInMemoryRealtimeAdapterbeing single-node and unpersisted (lines 225, 255, 332–335, 358, 440, 459, 610, 630) plus one line contrasting WebSocket fan-out as a different transport (722). All still true: no transport was wired and the adapter is unchanged. No edit.content/docs/kernel/contracts/index.mdxIRealtimeServiceas a publish/subscribe event system. The page does not enumerate the interface's members, and an additive optional member does not change that description. No edit.content/docs/plugins/packages.mdxservice-realtimeis in-process pub/sub, that there is no WebSocket/SSE endpoint and no client transport, and thathandleUpgradeis deliberately unimplemented. All still exactly true — this change mounts nothing, and the shipped adapter implements neitherhandleUpgradenor the newgetChannelRoute. No edit.content/docs/releases/v16.mdxwas flagged viaWellKnownCapabilitiesSchemaand is release-owned and read-only — not opened for writing. Read-only verdict: not falsified, nothing for anyone to fix. Its two hits are historical: line 239 records that schema-only surfaces were labelled "not yet enforced" in #3197 (those labels are untouched), and line 779 records thatWellKnownCapabilitiesSchemagainedtransactionalBatchin v16. This change adds no capability key; it changes howwebsocketsis computed, not the vocabulary.Filed rather than folded in: #15982 — the same capability bullet in
services-checklist.mdxalso names the retiredfeaturesmap spelling (#4828 renamed it tocapabilities) and the retiredworkflowslot (#4451). Both were wrong before this change and are not in its scope, so only the clause this change falsified was corrected here.Scope against the ruling, file by file
Ruling A is (a) realtime stays out of open core, (b)
/discoverystops advertising an unmounted realtime service, (c) "what counts as a subscribable channel" becomes one explicit definition.packages/spec/src/api/discovery.zod.tsisSubscribableChannel(),CHANNEL_SURFACE_SLOTS,readChannelRoute();enabled/websockets/routes.realtimedescriptionspackages/spec/src/contracts/realtime-service.tsgetChannelRoute()— the producer half of the definitionpackages/metadata-protocol/src/protocol.tsgetDiscovery()derives the realtime entry,routes.realtimeandcapabilities.websocketsfrom the predicatepackages/runtime/src/http-dispatcher.tsgetDiscoveryInfo()packages/services/service-realtime/src/realtime-service-plugin.tspackages/metadata-protocol/src/discovery-realtime-channel.pin.test.tsgetDiscoveryproducerpackages/runtime/src/discovery-realtime-channel.pin.test.tspackages/services/service-realtime/src/no-channel-route.pin.test.tspackages/objectql/src/protocol-discovery.test.tsenabled: truetofalsepackages/runtime/src/http-dispatcher.test.tspackages/qa/dogfood/test/authz-probe-blind-spot.census.tscontent/docs/protocol/kernel/realtime-protocol.mdx,content/docs/kernel/services-checklist.mdxcontent/docs/references/api/discovery.mdx,content/docs/references/api/protocol.mdx,packages/spec/api-surface/api.json,packages/spec/export-origins/api.jsonpnpm --filter @objectstack/spec run check:generated --fix, never hand-edited.changeset/discovery-subscribable-channel-definition.mdNo realtime transport, route, handler or client primitive is added anywhere; the only new members are the predicate, its slot set, its reader, and the optional contract method that lets a host outside open core answer it.
Two CI failures on
04b60ff7d, and what the second one cost1.
Dogfood Regression Gate—authz-probe-blind-spot.test.ts. The comment added torealtime-service-plugin.tsmoved a pinned exact control count: the census pattern is a bare/RealtimeService/g, so it matches insideIRealtimeServiceand prose about the symbol moves the symbol's count exactly as code does. Reproduced locally (exit 1, 1 failed / 32 passed), then re-measured in the ledger 10 → 11, which is what that pin is for — the comment was not reworded to push a counter back. Green after: exit 0, 33 passed. The row now carries a note for the next person who documents that file.2.
Lint & Repo Gates—check-type-source-resolution. The gate offers two remedies and refuses to choose between them from inside the failure. The discriminating question is whether the change ONBOARDED the program the dep is reached through. Measured againstorigin/main, not recalled:packages/runtime'stypecheckscript namestsconfig.test.jsonbyte-identically before and after this diff (the diff added one devDependency line and no script), so the(via tsconfig.test.json)program was already counted. That is the registry doc-block's "dep newly reached through a program that was ALREADY counted", i.e. the exposure the ratchet exists to catch — the re-baseline limb is closed and widening the entry was not available.Which remedy was taken: neither.
pathswas tried first, as the gate instructs, and measured: it clearedcheck-type-source-resolution(exit 0, registry unwidened) and then billed 13TS6059from@objectstack/service-realtime's file graph intopackages/runtime's test-typecheck ledger — a ledger that package cannot see, the shape PR #12570 measured — because the mandated rule pulls another package's source into a program whoserootDiris./src, whichtsconfig.test.jsonstates in its own header it will not widen. Its remedies are "fix them" (they are not code errors) or a maintainer-only ledger expansion. So the dist-resolved import was removed entirely: the devDependency, the vitest alias and the lockfile change are all reverted to the merge base, and the runtime pin declares its occupant locally.What that cost, measured rather than argued. The runtime pin no longer drives the shipped adapter — it drives a stand-in, in those words. One mutation, two pins: giving
InMemoryRealtimeAdapteragetChannelRoute()returning/api/v1/realtime(mutation proved on disk, marker 0 → 1 and blob changed; restore proved by blob equality and an emptygit diff HEAD) reddensservice-realtime'sno-channel-route.pin.test.ts(exit 1, 2 of 3 cases) and leaves the runtime pin green on all 4. So the assertion that lost evidential value is exactly the first one — "does NOT advertise an in-process realtime bus as a channel" no longer says anything about the shipped occupant.It is covered, not merely moved. That same mutation is what proves it: the shipped-occupant claim is pinned against the real class, in the package that owns it, and that pin has teeth. The two pins divide the reading — one pins what the shipped occupant names, the other that the producer derives from whatever an occupant names — and neither can go green by accident of the other. The stand-in is equivalent to the real adapter only on the two reads either producer performs on an occupant (
readChannelRoute,readServiceSelfInfo— bothundefinedon both objects, measured), which is a statement about today and precisely why the shipped-occupant claim lives next door. All of this is written at both code sites and in the changeset, not only here.Contract review
Clause-② : yes — re-declared from the diff, and it agrees with the ruling's own declaration. The published
/discoverypayload changes what it says forrealtime, and@objectstack/specgains three exported members plus one optional interface member on a published contract face. Additive in shape, changed in value.needs:contract-reviewis on this PR and on the card, and per the dispatch this PR parks: not flipped ready, auto-merge not armed.Client note
A client that keyed on
services.realtime.enabledto decide whether to subscribe now seesfalseand should poll or degrade. To ask whether the slot is occupied at all, readstatus('unavailable'= nothing registered,'degraded'= registered but reduced) — that is the questionenabledwas answering for this slot before. Cross-repo: this is the "the answer is poll" half of objectstack-ai/objectui#7249, which is not addressed here.Generated by Claude Code
Generated by Claude Code