Skip to content

feat(runtime): route ledger + conformance guard for the dispatcher↔client surface (#3563) - #3569

Merged
os-zhuang merged 2 commits into
mainfrom
claude/screen-flow-submit-resume-v9dqjx
Jul 27, 2026
Merged

feat(runtime): route ledger + conformance guard for the dispatcher↔client surface (#3563)#3569
os-zhuang merged 2 commits into
mainfrom
claude/screen-flow-submit-resume-v9dqjx

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

PR-1 of #3563: the inventory, the classification, and the ratchet. No runtime behavior change.

What this establishes

#3528's root cause was a class, not an instance: a route can exist, work, and be documented while @objectstack/client has no way to call it — and nothing fails. This PR makes the class visible and un-growable:

  • packages/runtime/src/route-ledger.ts — the audited disposition of every dispatcher route: sdk (client method named) / gap / server-only / public / dynamic / mismatch, each non-sdk row carrying its rationale.
  • The guard, split along the package boundary (each package verifies its own half):
    • packages/runtime/src/route-ledger.conformance.test.ts — every dispatcher domain (enumerated live from DomainHandlerRegistry.list(), plus the pinned legacy if-chain prefixes) must have a ledger entry, in both directions; ledger hygiene (sdk rows name their method, non-sdk rows carry a rationale); and the gap count ratcheted at 27 — closing a gap lowers the bound, raising it is an explicit reviewed decision.
    • packages/client/src/route-ledger-coverage.test.ts — every ledger entry naming a client method must resolve to a real function on an instantiated ObjectStackClient. The ledger is imported as a relative source file: pure data, zero imports.
  • docs/audits/2026-07-dispatcher-client-route-coverage.md — full findings + follow-up slicing.

Headline findings

Finding Scale
Dispatcher routes with no SDK expression 27 gaps across 6 domains
/actions (server-registered actions, 3 shapes) entirely unreachable from the SDK — the largest functional hole
/keys no SDK path to mint an API key at all
/share-links, /security zero expression
/packages 6 of 17 lifecycle routes expressed
Shape mismatches (client speaks REST dialect at dispatcher routes) 4 (analytics.meta/explain, both storage methods)
DEFAULT_DISPATCHER_ROUTES (spec) consumed by nothing in runtime; lists 2 nonexistent domains, omits 8 real ones — and CLIENT_SPEC_COMPLIANCE.md anchors "FULLY COMPLIANT" on it
Client surfaces with zero tests 5 surfaces / 62 methods (~36% of the SDK)
Client README documents 6 methods that do not exist
client.events (RealtimeAPI) non-functional in-memory stub

Also documented: dispatch branches with no HTTP mount (the dispatcher has no catch-all — /security/*, /share-links/*, /ui/view/* etc. are dead over HTTP in a plain runtime and only reachable through @objectstack/rest or a host catch-all), and the un-audited REST second surface (search / forms / reports / sharing rules / …) proposed as the next tranche.

Design notes

  • The ledger lives in runtime, not spec, deliberately: routes are declared in runtime, and the ledger is the guard's data, not public API. Promotion to @objectstack/spec (and retiring DEFAULT_DISPATCHER_ROUTES, a spec-major) is follow-up work.
  • The guard is split across the two packages because no runtime→client edge is buildable: @objectstack/client's own devDependencies point back at runtime (its Hono tests boot a real server), so a package edge closes a turbo build cycle (verified — turbo rejects the graph), and CI's per-package test tasks build only their own dependency closure, so a dist import is equally unavailable (the first push failed Test Core exactly this way). Skipping-when-absent was rejected: the client direction would never run in CI, and this guard exists precisely so that failure mode cannot go quiet.

Test plan

  • Both guard halves verified to bite: deleting the /keys ledger entry fails the runtime direction with the intended message; renaming automation.resumeautomation.resumeRun in the ledger fails the client direction.
  • Full suites: runtime 653 passed, client 117 passed (guards included).
  • ESLint clean on all touched files; the pre-existing action-execution.ts TS6133s under bare tsc -p reproduce identically on main and are untouched.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5

…ient surface (#3563)

#3528's root-cause class — a route that exists and works while
@objectstack/client has no way to express it — now has an inventory and a
ratchet.

route-ledger.ts records the audited disposition of every dispatcher route:
sdk (with the client method named) / gap / server-only / public / dynamic /
mismatch. The audit found 27 gaps across 6 domains — /actions is entirely
unreachable from the SDK (the largest hole), /keys has no SDK path to mint an
API key, /share-links and /security have zero expression, /packages exposes 6
of 17 lifecycle routes, plus 4 shape mismatches where the client speaks REST
dialect at dispatcher-only routes.

route-ledger.conformance.test.ts is the guard, and it bites in both
directions (each verified to fail when broken):

- a dispatcher domain registered with no ledger entry fails — a new route
  surface cannot land without a reviewed SDK disposition;
- a ledger entry claiming a client method that does not exist fails — the
  ledger cannot claim coverage the SDK does not have;
- the gap count is ratcheted at 27 — closing a gap lowers it, raising it is a
  reviewed decision.

The client is imported as a built artifact via a relative path deliberately:
a runtime→client package edge would close a turbo build cycle (client's own
devDeps point back at runtime for its Hono tests). A vitest subpath alias for
@objectstack/core/logger makes that dist resolvable under the test aliases.

Full findings — including the stale DEFAULT_DISPATCHER_ROUTES spec table that
CLIENT_SPEC_COMPLIANCE.md anchors its "FULLY COMPLIANT" claim on, mount-less
dispatch branches, the RealtimeAPI stub, six phantom methods in the client
README, five surfaces with zero tests, and the un-audited @objectstack/rest
second surface — live in docs/audits/2026-07-dispatcher-client-route-coverage.md
with proposed follow-up slicing.

No runtime behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 27, 2026 6:46am

Request Review

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling and removed size/m labels Jul 27, 2026
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/client, @objectstack/runtime.

26 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via packages/client)
  • content/docs/api/client-sdk.mdx (via @objectstack/client)
  • content/docs/api/data-flow.mdx (via @objectstack/client)
  • content/docs/api/environment-routing.mdx (via @objectstack/client)
  • content/docs/api/error-catalog.mdx (via @objectstack/client)
  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/client, @objectstack/runtime)
  • content/docs/kernel/runtime-services/data-service.mdx (via packages/client)
  • content/docs/kernel/runtime-services/index.mdx (via packages/client)
  • content/docs/permissions/authentication.mdx (via @objectstack/client, @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/client, @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/client)
  • content/docs/releases/implementation-status.mdx (via @objectstack/client, @objectstack/runtime)
  • content/docs/releases/v16.mdx (via @objectstack/client)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…y — CI has no client dist in runtime's test task

Test Core failed on the first push: the conformance test imported the built
client (`../../client/dist/index.mjs`), assuming the dist exists whenever
tests run. Wrong assumption — turbo's per-package test tasks build only their
own dependency closure, and runtime deliberately has no edge to client (the
reverse edge exists, so adding one is a build cycle turbo rejects). In CI the
dist therefore never exists for runtime's suite.

The fix is structural, not a skip: each package now verifies its own half.

- runtime keeps the dispatcher directions (registry domain ↔ ledger, both
  ways) plus ledger hygiene and the gap ratchet — no client import at all.
- client gains `route-ledger-coverage.test.ts`, which instantiates the real
  ObjectStackClient and asserts every ledger-named method resolves to a
  function. The ledger is imported as a relative SOURCE file: it is pure data
  with zero imports, and it lives in runtime because that is where routes are
  declared.
- the now-unneeded @objectstack/core/logger vitest alias is reverted.

Skipping when the dist is missing was rejected: Test Core would never
exercise the client direction, and the guard's whole point is that this
failure mode cannot go quiet again.

Verified: phantom-method negative check bites in the new home; client suite
117 passed, runtime suite 653 passed; eslint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5
@os-zhuang
os-zhuang marked this pull request as ready for review July 27, 2026 06:54
@os-zhuang
os-zhuang merged commit 48d5a1c into main Jul 27, 2026
16 checks passed
@os-zhuang
os-zhuang deleted the claude/screen-flow-submit-resume-v9dqjx branch July 27, 2026 06:54
os-zhuang added a commit that referenced this pull request Jul 27, 2026
…he 2 PR-1 (#3587) (#3609)

The dispatcher tranche (#3569..#3579) closed its 27 gaps and guards them;
this brings the same regime to the second, larger surface. All 89 routes
@objectstack/rest mounts now carry a reviewed disposition in
rest-route-ledger.ts: 38 sdk, 43 gap (ratcheted), 3 server-only, 3 public,
2 mismatch.

The guard enumerates BOTH sources for real — RouteManager routes via the
getRoutes() introspection seam that already existed, and the two
RouteManager-bypassing registrars (package-routes.ts,
external-datasource-routes.ts) via captured mock-server registrations —
so no pinned-by-hand list anywhere. The client-method direction lives in
packages/client next to the SDK (same package-boundary split as tranche 1;
a rest->client edge is unbuildable). All five guard directions were
negative-tested and fail with the offending route named.

Mismatches the audit surfaced, ledgered not fixed: POST /api/v1/packages
is a publish-vs-install shape collision between REST and the dispatcher
(REST registers first and wins); REST's GET /ui/view/:object/:type path
dialect is unreachable by the SDK's query-param dialect. The
service-storage / service-i18n autonomous mounts are a third surface,
explicitly scoped out and noted in the ledger header.

No behavior change: data + tests only, plus a scope-note refresh in the
runtime ledger.

Refs #3587


Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5

Co-authored-by: Claude <noreply@anthropic.com>
os-zhuang added a commit that referenced this pull request Jul 27, 2026
… tables under them (#3612) (#3616)

Five client families built URLs no server surface mounts (dispatcher, REST,
and autonomous service mounts all checked in the #3587 audit): permissions
x3, realtime x6, workflow x3, views x5, notifications device/prefs x4.
Every call was a guaranteed 404. Each family was underwritten only by an
unconsumed spec DEFAULT_*_ROUTES table — the same disease
DEFAULT_DISPATCHER_ROUTES had (#3586) — so DEFAULT_PERMISSION/VIEW/
WORKFLOW/REALTIME_ROUTES go with them; getDefaultRouteRegistrations()
returns 9. ApiRouteType loses its client-only 'views'|'permissions' extras.

Kept: events (local buffer, no HTTP), notifications list/markRead/
markAllRead (dispatcher-served), approvals.* (ADR-0019), and
meta.getLegalNextStates (the real FSM read). Docs/README/integration-test
spec swept; api-surface.json regenerated (-4 exports).

Re-adding any of these surfaces now requires the server route to exist and
a route-ledger row proving it (#3569/#3609 guards). The one external
consumer (objectui useClientNotifications dead delegates) is trimmed in a
companion objectui change.

Closes #3612


Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5

Co-authored-by: Claude <noreply@anthropic.com>
os-zhuang pushed a commit that referenced this pull request Aug 1, 2026
#4452)

The README's flow sections described a DSL that has never existed. Every node
type name was wrong (`record_create` vs `create_record`, `query` vs
`get_record`), the interpolation dialect was Salesforce's `{!…}` which the
platform does not parse, and branching/looping/error handling were written as
nested `steps` arrays -- a shape the schema has no key for. Nothing in it ran.

Since #4414 + #4439 this stopped being merely useless: `conditions[].expression`
is on the expression ledger, so the README's `'{!trigger.record.amount} > 10000'`
is now REFUSED at `registerFlow()` / `objectstack validate`. An author copying
it got a CEL error whose advice ("drop the braces") did not fix their node,
because the node's entire shape was wrong too.

Rewritten from the schemas and executors, not from the old README:

- Flows are a DAG of flat `nodes` + `edges`. Branching is an edge, and a
  decision routes by matching its branch `label` to an out-edge `label` --
  the #4414 trap, called out inline.
- The record-change binding lives on the `start` node's config
  (`{ objectName, triggerType, condition }`), not at the flow top level.
- CRUD config table: `objectName` (not `object`), `filter` as an OBJECT (not a
  `filters` array of triples), `outputVariable` (not `output`), no `recordId`.
  Notes that `update_record` has no `outputVariable` -- the executor reads none.
- Both expression dialects stated with the rule that disambiguates them: every
  condition is bare CEL, braces are for values. Says plainly that `{!…}` is not
  a dialect here.
- `loop` / `parallel` / `try_catch` given their real ADR-0031 region shape
  (`config.body`, `config.branches`, `config.try`/`catch`), and `wait` its
  node-level `waitEventConfig` block rather than the invented `duration` +
  `nextSteps`.
- Flow `type` values corrected (`schedule`, not `scheduled`).

Per the issue's preference, the per-node reference is NOT duplicated here: the
README now points at content/docs/automation/flows.mdx, the maintained one.
Keeping a second hand-written node catalog in a package README is the
#4027/#3569 shape that produced this drift.

`pnpm --filter @objectstack/spec check:generated`: all 8 artifacts up to date.

Fixes #4452
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 2, 2026
…ck-ai#4476, objectstack-ai#4486, objectstack-ai#4452) (objectstack-ai#4497)

* test(metadata-core): make the ReDoS guard load-insensitive (objectstack-ai#4485)

The ReDoS assertion in protocol-handshake.test.ts bounded the pathological
scan with an absolute 50ms wall clock. Under the full-repo run (~130 parallel
turbo tasks) that ceiling measures machine load rather than the parser: it
exceeded 50ms on a healthy tree and reddened PRs that never touched this
package, leaving the diagnosis cost to whoever happened to be running.

The underlying guard is real and stays (CodeQL 837/838). What changes is how
it is measured. The three `toBeNull()` behavioural assertions -- the actual
contract, that adversarial input is unrecognized rather than falsely rejected
-- are kept and now stand on their own. The wall-clock proxy is replaced by a
scaling check: the same adversarial shapes at 1x and 8x length, asserting the
parse stays linear in the input. Load largely cancels out of a ratio, which is
what makes the criterion load-insensitive.

Measured: healthy parsing tracks the input at 8.3-8.5x, stable across runs.
The 40x ceiling keeps ~5x headroom while still catching a merely quadratic
regression (~64x), let alone an exponential one, which would not finish.

Two measurement details are load-hardening, both established empirically:
timings are taken back-to-back within one iteration and reduced by minimum
*ratio* rather than minimising each timing independently (a scheduler steal
landing in only one window skewed the latter, observed reddening at 3x CPU
oversubscription); and the JIT is warmed so the baseline is not inflated.

Note the pathological-to-benign ratio suggested on the issue does not work
here: a benign 16-char range parses ~300x faster than a 100k-char one purely
because it is 100k characters shorter, so it would fail on a healthy machine.

Verification: 5/5 consecutive clean runs, plus 8/8 under 3x CPU
oversubscription (the shape that reproduced the original failure).

Fixes objectstack-ai#4485

* docs: date the v17 query-surface removals to 17, not 18 (objectstack-ai#4476)

Seventeen passages dated v17 removals to `@objectstack/spec` 18. They ship in
17 -- this train. The number is the actionable half of a removal notice: a
reader on 16 asking whether upgrading to 17 breaks their cursor-paginated loop
was told the removal is a major away, so they plan for it later and the
upgrade breaks.

Evidence that 17 is correct: `spec-changes.json` carries `toMajor: 17` for
data.query.{cursor,distinct,joins,windowFunctions} and stack.api.requireAuth;
this tree is 17.0.0-rc.1 with `PROTOCOL_VERSION = '17.0.0'`; and the keys are
already `[RETIRED]` in `authorable-surface.json`. A removal cannot be retired
in a 17 build and also ship in 18.

objectstack-ai#4476 fingerprinted nine locations. Grepping the bare pattern -- which the
issue itself recommended over working the list file-by-file -- found eight
more:

- The nine listed: query-syntax.mdx (4), queries.mdx (4), troubleshooting.mdx.
- query-syntax.mdx:98, the objectstack-ai#4286 sweep summary paragraph, same error.
- skills/objectstack-query/ (5): SKILL.md and the aggregation/pagination rules.
  These are agent-facing and the highest-leverage of the set -- an agent
  authoring a query reads them as ground truth. Body prose only, so the
  frontmatter-derived listings that build-skill-docs.ts generates are unchanged.
- implementation-status.mdx (2): the same error shape on a different change,
  `api.requireAuth` (objectstack-ai#3963), which spec-changes.json also puts at toMajor 17.

Also records the fingerprint in the sweep run log, as objectstack-ai#4476 asks. Runs 1-2
matched on surface names and so read past passages that named the right
surface and the wrong release; the new row tells the next run to check the
number, with spec-changes.json `toMajor` as the arbiter.

Fixes objectstack-ai#4476

* docs: restore the trailing options arg on IDataEngine reads (objectstack-ai#4486)

The `IDataEngine` block in data-engine.mdx wrote all four read methods with
two parameters, dropping the trailing `options?: BaseEngineOptions` that the
real contract gives each of them (packages/spec/src/contracts/data-engine.ts
:70/85/89/90).

The write methods in the same block each carried their own `options`, so the
block taught exactly the wrong model -- "writes take options, reads do not" --
and that is the misconception objectstack-ai#4251 existed to fix. The parameter is not
incidental: the same `{ context }` object is correct as insert's 3rd argument
but was SILENTLY DROPPED as find's, so an intended `isSystem` bypass vanished
and control-plane reads came back empty once org-scoping hooks landed. Anyone
-- human or agent -- writing code from this block was being led back to the
pre-objectstack-ai#4251 shape, against a failure mode that raises no error.

Adds `BaseEngineOptions` to the block's import list (the contract imports it
from the same module), and a callout recording the precedence the contract
states: `query.context` remains supported, and when both are given
`options.context` wins.

`content/docs/kernel/contracts/` is hand-written -- only `content/docs/
references/` is generated -- so no generator run is involved.

Fixes objectstack-ai#4486

* docs(service-automation): rewrite the README against the real flow DSL (objectstack-ai#4452)

The README's flow sections described a DSL that has never existed. Every node
type name was wrong (`record_create` vs `create_record`, `query` vs
`get_record`), the interpolation dialect was Salesforce's `{!…}` which the
platform does not parse, and branching/looping/error handling were written as
nested `steps` arrays -- a shape the schema has no key for. Nothing in it ran.

Since objectstack-ai#4414 + objectstack-ai#4439 this stopped being merely useless: `conditions[].expression`
is on the expression ledger, so the README's `'{!trigger.record.amount} > 10000'`
is now REFUSED at `registerFlow()` / `objectstack validate`. An author copying
it got a CEL error whose advice ("drop the braces") did not fix their node,
because the node's entire shape was wrong too.

Rewritten from the schemas and executors, not from the old README:

- Flows are a DAG of flat `nodes` + `edges`. Branching is an edge, and a
  decision routes by matching its branch `label` to an out-edge `label` --
  the objectstack-ai#4414 trap, called out inline.
- The record-change binding lives on the `start` node's config
  (`{ objectName, triggerType, condition }`), not at the flow top level.
- CRUD config table: `objectName` (not `object`), `filter` as an OBJECT (not a
  `filters` array of triples), `outputVariable` (not `output`), no `recordId`.
  Notes that `update_record` has no `outputVariable` -- the executor reads none.
- Both expression dialects stated with the rule that disambiguates them: every
  condition is bare CEL, braces are for values. Says plainly that `{!…}` is not
  a dialect here.
- `loop` / `parallel` / `try_catch` given their real ADR-0031 region shape
  (`config.body`, `config.branches`, `config.try`/`catch`), and `wait` its
  node-level `waitEventConfig` block rather than the invented `duration` +
  `nextSteps`.
- Flow `type` values corrected (`schedule`, not `scheduled`).

Per the issue's preference, the per-node reference is NOT duplicated here: the
README now points at content/docs/automation/flows.mdx, the maintained one.
Keeping a second hand-written node catalog in a package README is the
objectstack-ai#4027/objectstack-ai#3569 shape that produced this drift.

`pnpm --filter @objectstack/spec check:generated`: all 8 artifacts up to date.

Fixes objectstack-ai#4452

* chore: add release-nothing changeset for the v17 verification docs/test fixes

The Check Changeset gate requires every PR to add at least one
`.changeset/*.md` relative to base. This branch touches only `.md`/`.mdx`
prose and one `.test.ts` file -- verified against the diff, no package source,
no public export, no protocol change -- so the empty-frontmatter form is the
accurate declaration: it publishes nothing.

* docs(service-automation): note that parallel/try_catch are built-in too (objectstack-ai#4452)

The Node Types section presented `FLOW_BUILTIN_NODE_TYPES` (i.e. the
`FlowNodeAction` enum) as the built-in set, but the ADR-0031 structured
constructs `parallel` and `try_catch` ship registered builtin executors
without appearing in that enum -- which is exactly why `FlowNodeSchema.type`
validates against the live action registry rather than a closed enum. Left as
written, the list contradicted the Advanced Features section just below it.

* docs(service-automation): fix run-on sentence in the expressions section (objectstack-ai#4452)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants