Skip to content

fix(durability): a refused syncObjectSchema logs error at the messaging, datasource and publish seams; the gate reads .call/.apply/.bind and declares it - #23052

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22985-schema-sync-durability-family
Oct 12, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-22985-schema-sync-durability-family

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22985
Clause-②: no (log levels and an existing gate's vocabulary; no accepted shape and no published name changes)

What this changes

check:durability-log-level now sees a refused syncObjectSchema, and the four places where one was logged below error each give AGENTS.md "Degradation log levels" its measured answer.

member site answer evidence
5, the gate scripts/check-durability-degradation-log-level.mjs the guarded-side read resolves .call / .apply / .bind(recv)(…) to the function they invoke, through a scope-aware const alias; syncObjectSchema joins DURABILITY_CRITICAL_CALLEES red on the base members, green after (below); 5 self-test cases, 3 that must light and 2 that must not, each ablated
1 service-messaging provisionSystemTables error, both halves, once per table per boot, the refusal in meta (mirrors plugin-audit's line) the pipeline tables are written as a side effect: approval-service.ts and audit-writers.ts (@mention) catch a failed emit() at warn and complete, so the action succeeds while its notification never lands
2 service-datasource connect() bound-object loop error, both halves, once per object per connect pinned: after the refusal connectDeclared answers status: 'connected' and getConnectionState reads connected
3 metadata-protocol ensureObjectStorage console.error, both halves pinned: publishMetaItem answers success: true while the object's storage sync was refused
4 cli measureComposedCoverage the caller is told: declared in FAILURE_PROPAGATION_SITES (failures.set, effect); the cli source is unchanged not a durability seam (see below)

The gate (member 5)

calleeName() is untouched; the read-seam rule and helper following still use it. A new guardedCriticalCallee() is used only by collectGuardedCalls. It reads the invoked operation first and the plain calleeName() second, so reach is only added. Scope rules for the alias: the nearest enclosing declaration wins, and a parameter, a catch binding, a loop variable or a function declaration of the same name shadows it. Only a const is an alias, because a let can be reassigned. A bare .bind(recv) invokes nothing, so it is not a guarded call. No new rule and no new gate.

Measured on the tree, in order:

state seams red
base edecd4485 46 0
unwrap only, vocabulary unchanged 46 0 (no existing seam lost or gained)
syncObjectSchema entry only, no unwrap (scratch copy) 49 3: members 2, 3, 4. Member 1 and the audit site are invisible because sync.call reads as call
gate change a91725f9e (unwrap + entry), members unfixed 51 4: members 1–4. The audit site is a seam and loud
merged 7d7de7be4, members answered 51 0 (7 propagating, declared)

Red run at a91725f9e, --list filtered to the five syncObjectSchema seams:

packages/cli/src/utils/schema-migration-plugins.ts:2401  guards syncObjectSchema()@2399  → SILENT
packages/metadata-protocol/src/protocol.ts:19405  guards syncObjectSchema()@19404  → QUIET (warn@19406)
packages/plugins/plugin-audit/src/audit-plugin.ts:653  guards syncObjectSchema()@652  → loud (error@660)
packages/services/service-datasource/src/datasource-connection-service.ts:658  guards syncObjectSchema()@657  → QUIET (warn@659)
packages/services/service-messaging/src/messaging-service-plugin.ts:491  guards syncObjectSchema()@490  → QUIET (warn@492)
✗ 4 durability report(s) degrade quietly

Green, on the merged head:

✓ durability-degradation log levels: 51 durability-critical catch seam(s), all loud, rethrowing or propagating to the caller (7 propagating, declared); 2 counter-guarded summary report(s) outside a catch, all of them able to print against a sink with no `error` (#9748).

Member 2: why error, and what it does not claim

The catch cannot tell a managed object from a federated one, and both end up registered and served with no working storage while the datasource reads connected. A managed object's DDL never ran. A federated object's remote-table binding was never installed, so its reads go to a table named after the object. The loop only runs from connectDeclared: runtime-admin connects pass no objects. Every boot calls connectDeclared, so the fix the line names is "resolve the refusal, then restart". os migrate is not named, because it plans only the primary datasource and its telemetry sibling (schema-migrate.ts). For a federated object, ObjectQLPlugin.reconcileFederatedBindings re-drives the binding at kernel:ready and reports what is still unbound at error. A federated refusal can therefore print twice, and both lines are true. The logger contract has warn required and error optional, so the call is spelled (logger.error ?? logger.warn).call(logger, …), never error?.(…). A pin holds the { warn }-only sink.

Member 4: delivery shape, measured

  • The catch has no return. It runs failed++ and failures.set(message, …), then falls off the end. After the loop, every failures entry becomes one note of the returned { coverage, notes }. os migrate plan / apply print those notes (plan.ts, apply.ts) next to coverage.reasons.failed. That is the CALL shape, which AGENTS.md declares through the propagation vocabulary. It is the same shape as duplicatePackage's failed.push.
  • The repo's own instrument agrees. With syncObjectSchema in the vocabulary, measure-return-propagating-durability-seams.mjs --sites lists 0 members in "[1] the gate SEES this seam today", so this catch is not a RETURN-shaped member.
  • It is not a durability seam at all. This pass runs only on a boot that DEFERRED its DDL, so the guarded call registers metadata in memory and writes nothing.
  • The existing pin schema-migration-plugins.test.ts › "reports a REFUSING driver as a shortfall, quoting it" holds reasons.failed and the note.
  • No checker change was needed to excuse it, and no ⛔ move was made: no baseline entry, no logger.error, no DURABILITY_CRITICAL_CALLEES entry for the site.

Pins and ablations

Each error member has a forced-refusal test that asserts exactly one line, naming the subject and the remedy. The line has no newline, and the refusal travels in meta or as a separate argument. Each also asserts no quieter duplicate and has a CONTROL in which a clean run logs no error. Every negative pin was ablated through scripts/ablation-replace.mjs. In each leg the anchor hit once, the blob changed, and the restore was proven: blob equals HEAD and git diff HEAD is empty.

leg (mutation) red at
messaging error → warn 8 of 9 (every object; CONTROL green) 8a59d7e0a
datasource fallback → logger.warn.call 2 of 4 8a59d7e0a
datasource fallback → logger.error?.call 1 of 4 (the { warn }-only sink) 8a59d7e0a
protocol console.error → console.warn 1 of 6 (CONTROL and the four existing cases green) d80c6d124
gate: guarded read back to plain calleeName 3 self-test cases (sync.call flags; cast alias is a seam; apply/bind ×3) 8a59d7e0a
gate: bare .bind read as an invocation the must-not-light bind case 8a59d7e0a
gate: parameter no longer shadows the must-not-light shadow case 8a59d7e0a
gate: let trusted as alias the must-not-light shadow case 8a59d7e0a
gate: member 4's site key renamed real tree red at the cli site + a stale-site report 8a59d7e0a

Files beyond the claim's surface, and why

  • packages/metadata-protocol/src/protocol.record-package-commit-durability.test.ts: its stub engine gains syncObjectSchema. HostEngine declares it, and an object publish calls it. Without it the publish threw a TypeError, which the protocol now reports at error, so the file's "logs nothing" control went red (7 failures, measured). The alternative, a typeof guard in ensureObjectStorage, would have been consumer-side tolerance of an off-contract engine.
  • packages/metadata-protocol/src/protocol.publish-side-effects-canonical-type.test.ts: member 3's pin lives here. Its stub already carries the publish path and is already in both double ledgers. A copied stub in a new file reddened check:engine-double-contract (3 RETAINED) and check:objectql-double-limit (1 BLIND). The stub's syncObjectSchema now takes a set of refused names.
  • scripts/measure-durability-swallow-family.mjs: a by-value copy of the gate vocabulary, self-tested against it. check:swallow-census-controls reds on the drift until the copy gains syncObjectSchema.
  • ensureObjectStorage's docblock gained two sentences, beside its catch.

Verification

Checks run at 7d7de7be4 (origin/main merged once, at 7d7de7be4; full turbo run build after the merge):

  • pnpm --filter @objectstack/service-messaging test: 48 files, 549 passed; typecheck green.
  • pnpm --filter @objectstack/service-datasource test: 43 files, 769 passed; typecheck green.
  • pnpm --filter @objectstack/metadata-protocol exec vitest run: 227 files passed, 3 skipped; 28192 passed; typecheck green.
  • tsc --listFiles includes each touched test file in its package's program.
  • Gates: the 89 commands node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives for this change set (it includes everything the dispatch listed except check:i18n-coverage and check:i18n-walk-parity, which were run as well), plus measure-return-propagating-durability-seams.mjs --self-test and measure-durability-swallow-family.mjs --self-test. All 93 exited 0, and the exit code was captured before any pipe. dispatch-gates --ran with those codes reports 89 derived famil(ies) accounted for — 89 run, 0 NOT-MEASURED (a DERIVED zero — all 89 recorded an exit code and none of them is 3). This includes check:durability-log-level (self-test 68 + 57 cases), check:swallow-census-controls, check:engine-double-contract, check:objectql-double-limit, check:dual-build-cjs-loads and check:i18n. The last two read built output, so they ran after the post-merge full build.
  • Lint, narrowed and proven: eslint --no-inline-config --format json over the 8 changed .ts/.mjs files. The JSON lists all 8 as linted and none as ignored: 0 errors, 0 warnings. The population is all of the changed code files, read from eslint's own output. The narrowing cannot move any other file's verdict, because eslint.config.mjs enables no type-aware linting (no parserOptions.project, no typed rules; its own header says so).
  • NOT MEASURED:
    • cli tests: no cli source changed, so the unit layer is not owed and integration is left to CI.
    • Downstream suites that drive the real protocol (objectql, rest, runtime): not run locally. A static read of every test there that spies console.error found no count-based assertion over an object publish whose engine lacks syncObjectSchema.
    • Member 1 at a public door after the fix (os serve): not run. The base-side reading by the plugin-audit: a failed DDL for an audit table at boot is logged at warn and the boot stays green #22913 dev stands, and the unit pin holds the level.

Acceptance notes

  • Observed, not filed (reach not measured): under OS_SKIP_SCHEMA_SYNC=1, member 2's loop still issues DDL for a managed object bound to a declared datasource. connectDeclared is called unconditionally from AppPlugin.start(), and syncObjectSchema is ungated. This is the shape plugin-audit: provisionSystemTables creates tables under OS_SKIP_SCHEMA_SYNC=1, while objectql's boot says it registered every schema WITHOUT DDL #22984 repaired at the two provisioning sites. It is code reading only, with no public-door measurement.
  • Doc drift: ObjectQLPlugin.reconcileFederatedBindings' docblock says DatasourceConnectionService re-drives "not at all when OS_SKIP_SCHEMA_SYNC is set". No such gate exists in AppPlugin or the connection service.
  • The two census instruments (measure-return-propagating-durability-seams.mjs, measure-durability-swallow-family.mjs) keep their own guarded-call readers, which do not resolve .call. They read the gate's vocabulary but are not the gate.

Generated by Claude Code

…e guarded side; declare syncObjectSchema

The guarded-side callee read used the property a call is spelled through, so
`sync.call(engine, name)` read as `call`. It now resolves the adapter to the
function it invokes, through a scope-aware const alias, the way the log side
has read the same adapters for a long time. The plain reading is still
consulted, so reach is only added. `syncObjectSchema` joins the vocabulary.

On this commit the checker is RED on the four members it now sees
(service-messaging, service-datasource, metadata-protocol, cli); the plugin-audit
site is seen and already loud. The members are answered in the next commits.

Claude-Session: https://claude.ai/code/session_01CeRCvvvjxbxu2iYJAc5Cce
Co-authored-by: Claude <noreply@anthropic.com>
…d fix; the migrate-plan catch is declared as delivering

- service-messaging provisionSystemTables: one `error` per refused object per
  boot, consequence + fix, the refusal in meta (mirrors plugin-audit's line).
- service-datasource connect: one `error` per refused bound object per
  connect, spelled `(logger.error ?? logger.warn).call(...)` against the
  warn-required sink contract.
- metadata-protocol ensureObjectStorage: `console.error` with consequence +
  fix after a publish that already answered success.
- cli measureComposedCoverage: not a durability seam (deferred DDL); its catch
  delivers each refusal into the returned plan notes through `failures.set`,
  declared in FAILURE_PROPAGATION_SITES.

check:durability-log-level: 51 seams, all loud, rethrowing or propagating.

Claude-Session: https://claude.ai/code/session_01CeRCvvvjxbxu2iYJAc5Cce
Co-authored-by: Claude <noreply@anthropic.com>
…yncObjectSchema

`HostEngine` declares it and an object publish calls it. Without it the
publish's storage sync threw a TypeError, which the protocol now reports at
`error`, and the file's "logs nothing" control read that as a commit-store line.

Claude-Session: https://claude.ai/code/session_01CeRCvvvjxbxu2iYJAc5Cce
Co-authored-by: Claude <noreply@anthropic.com>
…essaging, service-datasource and metadata-protocol

Claude-Session: https://claude.ai/code/session_01CeRCvvvjxbxu2iYJAc5Cce
Co-authored-by: Claude <noreply@anthropic.com>
…stub that is already ledgered; census copy learns syncObjectSchema

The member-3 pin moves into protocol.publish-side-effects-canonical-type.test.ts,
whose stub already carries the publish path and is already in the engine-double
and limit ledgers, instead of a copied stub that both gates would have to learn.
The stub's syncObjectSchema takes a set of refused names.

measure-durability-swallow-family.mjs copies the gate vocabulary by value and
self-tests the copy, so it gains syncObjectSchema with the gate.

Claude-Session: https://claude.ai/code/session_01CeRCvvvjxbxu2iYJAc5Cce
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/service-datasource, @objectstack/service-messaging, touching 12 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via sys_inbox_message (literal, a string literal in lostWithoutStorage), sys_notification_receipt (literal, a string literal in lostWithoutStorage))
  • content/docs/automation/hook-bodies.mdx (via sys_notification (literal, a string literal in lostWithoutStorage))
  • content/docs/automation/webhooks.mdx (via MessagingServicePlugin (symbol, a top-level class))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/concepts/north-star.mdx (via sys_notification_receipt (literal, a string literal in lostWithoutStorage))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v14.mdx (via MessagingServicePlugin (symbol, a top-level class))
  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), sys_notification (literal, a string literal in lostWithoutStorage))
  • content/docs/releases/v17/17-1.mdx (via sys_inbox_message (literal, a string literal in lostWithoutStorage))
  • content/docs/releases/v17/17-5.mdx (via MessagingServicePlugin (symbol, a top-level class), sys_notification (literal, a string literal in lostWithoutStorage), sys_notification_delivery (literal, a string literal in lostWithoutStorage))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json df7a137923be503bf6fea15a6fc944d3ba06df6f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b151e7850f9283e0c900392679a7a412f58664c5 — the merge of head 7d7de7be40859fd84e17f025f047d6a760116793 into base df7a137923be503bf6fea15a6fc944d3ba06df6f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b151e7850f9283e0c900392679a7a412f58664c5 && git checkout b151e7850f9283e0c900392679a7a412f58664c5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin df7a137923be503bf6fea15a6fc944d3ba06df6f 7d7de7be40859fd84e17f025f047d6a760116793 && git checkout -B drift-repro df7a137923be503bf6fea15a6fc944d3ba06df6f && git merge --no-ff 7d7de7be40859fd84e17f025f047d6a760116793

node scripts/docs-audit/affected-docs.mjs --json df7a137923be503bf6fea15a6fc944d3ba06df6f

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs df7a137923be503bf6fea15a6fc944d3ba06df6f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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/l tests tooling

Projects

None yet

2 participants