Uh oh!
There was an error while loading. Please reload this page.
Ledger convergence: registration home + one store implementation (ADR-0126 §4/§8, maintainer-ruled) - #12419
Conversation
…its declaration (#12359) `sys_metadata_activation` is declared in this package, beside `SysMigration` / `SysMigrationJournal` / `SysSecret`, but the only thing that REGISTERED it was the automation service's manifest — because flows were the ledger's first and, until packaged actions landed, only consumer. Actions are a second consumer with a different owner: the consult and the write path live on the ObjectQL engine, which is present in every composition that can execute an action, automation service or not. So a deployment with actions and no automation service had no ledger table, and the activation door answered 503 SERVICE_UNAVAILABLE on every flip — correctly (a flip that cannot be made durable must not be reported as one, ADR-0126 section 6 wall 3) and permanently. Registration now follows the DECLARATION. Maintainer ruling, 2026-08-26, verbatim and untranslated: 「同意」. Every composition carrying platform-objects has the ledger, so packaged disable works without the automation service and each future ADR-0126 section 8 consumer (`tool`, `skill`, `position`) inherits it. MOVE, not add — single owner, one registration. The automation service no longer names the object in its own manifest: two manifests owning one object is the governed contributor case (ADR-0029 D7), and the ruling closed it by naming one owner rather than by declaring a double registration benign. A grep-level pin in `plugin.test.ts` is what a later edit re-adding it has to argue with, since the two registrations live in different packages and neither one's unit tests can see the other. `runObjectRegistered` no longer vouches for this table, and the comment at the flow store's attach site now says so — the `probe()` beside it is the gate that actually decides, and always was. The attach policy itself is untouched: this moves a registration, not a policy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh
…on-ledger row contract (#12350) ADR-0126 section 4 declares ONE activation ledger for the whole disable+clone family. It had two independent implementations of that one row contract: ObjectStoreFlowActivationStore @objectstack/service-automation #12296 ObjectStoreActionActivationStore @objectstack/objectql #12348 They agreed on every load-bearing detail because the second was written from the first, and nothing structurally held them together. Section 8 pre-charts `tool`, `skill` and `position` as later consumers, and a third and fourth copy is where the row semantics start drifting — the org-row skip and the `0`-is-false read are exactly the kind of detail a copy loses quietly, in the direction (an artifact silently re-arming) nothing else measures. Neither consumer could import the other: `service-automation` does not depend on `@objectstack/objectql` (devDependency only), and the engine must not depend on a service. `@objectstack/core` is the package BOTH already depend on, so it is the one home that needs no new edge — the same reasoning that put `recordNotFoundError` there for the ADR-0076 D2 closure. NOT `@objectstack/platform-objects`, which declares the OBJECT: `objectql` does not depend on it and that edge would invert the tiering. Where the code lives is a module-import question; where the object's registration lives is a composition question with its own answer. `ObjectStoreMetadataActivationStore(engine, metadataType)` carries the row semantics once. Each consumer keeps its own name, its own one-argument constructor and its own docs, and fixes the discriminator — a binding, not an implementation. A caller that had to pass the discriminator could pass the wrong one; each leg has exactly one correct value. Byte-equivalent semantics: install-level rows only (`organization_id` never written), org-carrying rows skipped on read AND ignored when deciding insert-vs-update, a driver `0` read as false, read-then-write rather than a blind upsert, no `delete` in the engine slice because re-enabling rewrites the row. Both existing pin suites stay green UNCHANGED — they pin the contract from each binding's side, so their staying green is the proof the consolidation lost nothing. The new suite beside the implementation pins what neither of them can: that the discriminator is a PARAMETER rather than a constant, that two bindings over one table see none of each other's rows in either direction, and that a type nobody has written yet behaves identically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh
…he registrar move (#12359) Measured while discharging this card's smooth-upgrade wall, and it is not what the ruling considered: a manifest is not only a list of objects, it is also a ROUTING decision. `ObjectQL.resolveDatasourceBinding` step 4 routes an object by its OWNING PACKAGE's `defaultDatasource`, so the registrar carries the table's datasource with it. Measured on a real engine holding a default driver plus one named `cloud`, registering the same object definition under each manifest in turn: owner com.objectstack.service-automation (defaultDatasource: 'cloud') -> resolveEffectiveDatasource('sys_metadata_activation') === 'cloud' owner com.objectstack.platform-objects (no defaultDatasource) -> resolveEffectiveDatasource('sys_metadata_activation') === undefined (rides the global default driver — a DIFFERENT database) The ledger table already exists in live databases. On any deployment carrying a `cloud` datasource, folding the object into the platform-objects manifest would leave its rows in one database and start reading the other: every artifact an administrator had switched off would silently re-arm — the ADR-0126 section 6 wall 3 failure arriving through a door nobody was watching. So the ledger is registered under its OWN manifest from the same plugin, carrying the automation manifest's scope / namespace / defaultDatasource triple verbatim. Single owner, one registration, routing byte-identical everywhere. The three siblings deliberately do NOT get the triple — they ride the project database today and moving them is a larger question. It is also NOT spelled as `datasource: 'cloud'` on the object: that is binding step 1, which answers even when no such driver exists, so `getDriver` would throw on every deployment without a control plane. Which datasource this ledger SHOULD live on is a real question and this refuses to answer it by accident, not on purpose. Also measured, because the triage that raised #12359 left it as the open question: a double registration is NOT benign. `registerObject` throws `Object "sys_metadata_activation" is already owned by package "…"` the moment a second code package claims the name (ADR-0029 D3/D7), so adding a registrant would have been a boot failure rather than a duplicate — MOVE was the only shape available. Both ends of the move are now measured on real boots rather than reasoned about (`packaged-activation-ledger-reach.dogfood.test.ts`), each with its own anti-vacuity control on whether the automation service is actually composed: - actions and NO automation, the exact boot #12359 measured: the flip that answered 503 SERVICE_UNAVAILABLE answers 200, the row is install-level (`organization_id` NULL, `metadata_type: 'action'`), dispatch refuses the disabled action 409 ACTION_DISABLED ahead of the param contract and the record load, and re-enabling updates the row rather than deleting it; - WITH automation: exactly one owner, the datasource binding unchanged, both projections hydrating from the same table, and neither consumer's rows visible to the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh
…ere the file lives The "the automation service no longer registers the ledger" pin started life in `platform-objects/src/plugin.test.ts`, reading the automation plugin's source across the package boundary. `check:cross-package-test-inputs` is right to refuse that: a test whose real inputs are wider than its package is invisible to both the affected-subset filter and the turbo cache, so it can go red on `main` while every PR reports green (#7802). Moved, and improved on the way. It is now a BEHAVIOURAL pin in the package that owns the file — a real `init()` against a recording manifest service, asserting the registered object list by EQUALITY — rather than a grep over module text. A grep passes against an object added through a different spelling, and this is the kind of invariant a well-meaning refactor re-breaks without ever typing the old symbol name. What it guards is not a duplicate but a BOOT FAILURE: `registerObject` throws `Object "sys_metadata_activation" is already owned by package "…"` (ADR-0029 D3), and `objectstack serve` auto-injects platform-objects unconditionally, so every served kernel would fail to start. Also records the new store suite's engine double in the RETAINED coverage ledger (`--write`, grow-only, +1 row) — new pinned coverage the gate asks for by name, not a debt baseline movement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWRU3s15AJz7PGW7a7wdCh
📓 Docs Drift CheckThis PR changes 4 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 2 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 32 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 a9b88b4c1f9c90b74c34cc3fd5c536db0244e050 && git checkout a9b88b4c1f9c90b74c34cc3fd5c536db0244e050
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2e24115710640fba45938baf24a31fd9cc80694f 3ad9030e7078c8bf7325e0e66e8241f2837d07e8 && git checkout -B drift-repro 2e24115710640fba45938baf24a31fd9cc80694f && git merge --no-ff 3ad9030e7078c8bf7325e0e66e8241f2837d07e8
node scripts/docs-audit/affected-docs.mjs --json 2e24115710640fba45938baf24a31fd9cc80694f
|
os-support-ai
commented
Aug 26, 2026
Clause-② contract review — PASS (PM session Wire surface: zero change. No route added, removed or reshaped; no error code added (the What did change is composition-level, and each shift is measured, not asserted:
Part 3 is concluded by maintainer ruling (option A, decline-and-record — #12159 comment 5419767057); no code, so nothing to review. The ledger's datasource home stays as-is by design, recorded as informational. Proceeding to ready + queue per the program's standing authorization. Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
…ge (#12457) ADR-0126 SS7.4's Setup page shipped complete in the console (objectui app-shell, component ref automation:packaged) but no framework NavigationContribution ever named the ref - the page was reachable only by a hand-typed URL and Setup's sidebar carried no entry (epic #12150 L5/L6-UI closed with the framework half missing). - SETUP_NAV_CONTRIBUTIONS: nav_packaged_automation (type component, componentRef automation:packaged) in group_apps beside Packages (ADR-0084: package administration is Operate). Deliberately no requiresService (action switches work without the automation service, #12419) and no requiredPermissions (matches nav_packages; write doors enforce manage_metadata / the SS5 operator gate server-side) - labels in all four locales + recorded source hashes - setup-packaged-automation-nav.test.ts pins the framework half of the cross-repo contract (objectui's navContribution test pins its half) - FOLLOW-UPS SS8a D16 row updated with the filed issue ref - changeset: @objectstack/platform-objects patch Validated: platform-objects vitest suites green (204 tests incl. i18n parity/tombstone/source-hash), check:app-nav-i18n green (54 merged setup nav ids, 4 locales). Fixes#12457. Refs #12438, Epic #12150, ADR-0126 SS7.4 Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SKUXt6sKgAeCEtjiuwNuhq
Closes#12159
Closes#12350
Closes#12359
The maintainer-ruled "ledger convergence" bundle (2026-08-26, live PM chat, verbatim and untranslated: 「同意」 — full record on #12359 comment 5419050253). Three parts were chartered; two are implemented here and the third is a measured stop-and-report, per the card's own instruction not to choose for the maintainer when the choice is customer-visible either way. Each part is one commit, verifiable independently in the diff.
Verified at
3ad9030e7— every gate result below was re-run on that commit.Part 1 — registration follows the declaration (#12359) ·
9b0e0657+1f309525+3ad9030e7sys_metadata_activationis declared in@objectstack/platform-objects, but the only thing that REGISTERED it was the automation service's manifest — because flows were the ledger's first and, until packaged actions landed, only consumer. Packaged actions are a second consumer with a different owner: their consult and write path live on the ObjectQL engine, present in every composition that can execute an action.So a deployment with actions and no automation service had no ledger table, and the activation door refused every flip — correctly (ADR-0126 §6 wall 3: a flip that cannot be made durable must not be reported as one) and permanently.
PlatformObjectsPluginregisters it now.MOVE, not add. That was not a style choice, and the question #12359's triage left open ("whether that double registration is benign was not measured") is now measured: it is not benign, it throws.
ADR-0029 D3's single-owner invariant. Adding a registrant would have been a boot failure for every kernel
objectstack serveproduces (it auto-injects platform-objects unconditionally), not a duplicate — so MOVE was the only available shape, whichever way the ownership question was answered.The smooth-upgrade wall, discharged by measurement rather than assertion
The card required proving the move is a no-op for existing data. It is not unconditionally, and finding out why changed the implementation.
A manifest is not only a list of objects — it is also a ROUTING decision.
ObjectQL.resolveDatasourceBindingstep 4 routes an object by its owning package'sdefaultDatasource, so the registrar carries the table's datasource with it. Measured on a real engine holding a default driver plus one namedcloud, registering the same object definition under each manifest in turn:defaultDatasourceresolveEffectiveDatasource('sys_metadata_activation')com.objectstack.service-automation'cloud''cloud'com.objectstack.platform-objectsundefined— rides the global default driver, a different databaseThe ledger table already exists in live databases. On any deployment carrying a
clouddatasource, the naive fold would have left its rows in one database and started reading the other: every artifact an administrator had switched off would silently re-arm — precisely the ADR-0126 §6 wall 3 failure, arriving through a door nobody was watching.So the ledger rides its own manifest from the same plugin, carrying the automation manifest's
scope/namespace/defaultDatasourcetriple verbatim. Single owner, one registration, routing byte-identical everywhere.sys_migration,sys_migration_journal,sys_secret) deliberately do not get the triple — they ride the project database today and moving them is a larger question.datasource: 'cloud'on the object: that is binding step 1, which answers even when no such driver is registered, sogetDriverwould throwDatasource 'cloud' … is not registeredon every deployment without a control plane. Step 4 is conditional on the driver existing, which is exactly the behaviour this object has today.Both ends measured on real boots
packages/qa/dogfood/test/packaged-activation-ledger-reach.dogfood.test.ts— 11 tests, each describe carrying its own anti-vacuity control on whether the automation service is actually composed.Actions and NO automation — the exact boot #12359 measured:
and then, on the same boot: the row is install-level (
metadata_type: 'action',organization_idNULL,activefalse through the 0/1 spelling); dispatch refuses the action 409 ACTION_DISABLED ahead of the param contract and the record load; re-enabling updates the row rather than deleting it (§6 wall 3 — the ledger records the administrator's choice) and dispatch re-arms.WITH automation — flows and actions both keep working: exactly one owner for the ledger object; the datasource binding unchanged; the action projection hydrating (flip lands, dispatch refuses) and the flow projection hydrating from the same table (
POST /automation/showcase_task_completed/toggle→ 200 with a durable row); and neither consumer's rows visible to the other.Part 2 — one implementation of the §4 row contract (#12350) ·
6064eaecADR-0126 §4 declares one activation ledger; it had two independent implementations of that one row contract —
ObjectStoreFlowActivationStore(service-automation, #12296) andObjectStoreActionActivationStore(objectql, #12348). They agreed because the second was written from the first, and nothing structurally held them together. §8 pre-chartstool,skillandposition, and a third and fourth copy is where the org-row skip and the0-is-false read get lost quietly, in the direction — an artifact re-arming — that nothing else measures.Neither consumer can import the other (
service-automationdoes not depend on@objectstack/objectql; the engine must not depend on a service), so the contract now lives once in@objectstack/core— the package both already depend on, and the same reasoning that putrecordNotFoundErrorthere for the ADR-0076 D2 closure. ⛔ Not@objectstack/platform-objects, which declares the object:objectqldoes not depend on it and that edge would invert the tiering. Where the code lives is a module-import question; where the object's registration lives is a composition question with its own answer, above.ObjectStoreMetadataActivationStore(engine, metadataType). Each consumer keeps its own name, its own one-argument constructor and its own docs, and fixes the discriminator — a binding, not an implementation. A caller that had to pass the discriminator could pass the wrong one; each leg has exactly one correct value.No API break.
ObjectStoreFlowActivationStore/InMemoryFlowActivationStore/FlowActivationStoreEngineandObjectStoreActionActivationStore/InMemoryActionActivationStore/ActionActivationRow/ActionActivationStore/ActionActivationStoreEngine/ACTION_ACTIVATION_TABLEare exported from the same modules with the same shapes.The pin suites are the proof, and the proof was tested
Both pin suites stay green byte-unchanged — neither file is in this diff. That claim is only worth something if they actually reach the consolidated code, so it was ablated: the org-row skip was removed from the one shared implementation,
@objectstack/corewas rebuilt, the marker was confirmed present indist/byscripts/ablation-dist-preflight.mjs, and both suites went red on their own org-skip assertion:Restore leg re-run and re-verified absent from
dist/(12 built files). (A first ablation attempt is not quoted: its marker string contained/, which broke the replacement's confirmation leg. The run above is the clean one.)The new suite beside the implementation pins what neither consumer suite can: that the discriminator is a parameter rather than a constant, that two bindings over one table see none of each other's rows in either direction, and that a type nobody has written yet (
tool) behaves identically.Part 3 — permission-set convergence: stopped and reported
The card requires a decision note on the issue before this part's code, and instructs stopping rather than choosing if the choice is customer-visible either way. The full note with all measurements is on #12159. In short, measured on a real boot:
sys_permission_setrows carry nopackage_idon a stock boot, and every clone — the exact remedy the Studio save of a package-declared permission set forks it into a silent, undiscoverable overlay #11513 packaged-set lock names — adds another (managed_by:'admin',package_id:null, driven end-to-end:POST /data/sys_permission_set→ 201,PATCH {active:false}→ 200). The ledger'spackage_idisrequired: true. Separately,sys_permission_setisunique:'organization'onnamewhile the ledger'sorganization_idis reserved and unwritten (§5) and skipped on read — collapsing per-org sets onto one install-level row is the Decide whether POST /api/v1/automation/:name/toggle belongs in the manage_metadata write set — it mutates flow enablement with no authoring capability #10243 leak the ledger exists to retire, from the other direction. And a declared index, two Setup list views, two lifecycle actions, the clone action and the explain panel'sdeactivatedverdict are all bound to the column.sys_permission_set.activebypassing the permission-set backfill (ADR-0094 D4) 现在 100% 失败:行里的active存储列喂进了 #4001 之后严格化的 permission spec #4669 row-state carve-out ordering and the Studio save of a package-declared permission set forks it into a silent, undiscoverable overlay #11513 packaged-set lock pre-pass.There is also a family the card does not mention:
sys_position.active(16 rows, 0 packaged) andsys_capability.active(10 rows, 2 packaged) are the same ADR-0049 switch judged by the sameisRowActivepredicate, so whatever answer fits here has to be argued for all three.Parts 1 and 2 land independently of however this is decided; no rework needed either way.
Verification
Repo-wide
pnpm lint(eslint . --no-inline-config) — exit 0, whole tree, no narrowing.Full suites of every touched package, all at
3ad9030e7:@objectstack/coreTest Files 39 passed (39) · Tests 968 passed (968)@objectstack/platform-objectsTest Files 31 passed (31) · Tests 497 passed (497)@objectstack/service-automationTest Files 91 passed (91) · Tests 1082 passed (1082)@objectstack/objectqlTest Files 235 passed (235) · Tests 4174 passed (4174)Test Files 1 passed (1) · Tests 11 passed (11)Test Files 6 passed (6) · Tests 45 passed (45)@objectstack/runtime(activation dispatch + posture)Test Files 2 passed (2) · Tests 36 passed (36)typecheckgreen forobjectql,platform-objects,dogfood,runtime(coreandservice-automationdeclare notypecheckscript).Gate families derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived after the diff grew, and re-run on the final head. All exit 0; each gate's own verdict line:check-engine-double-contract: OK — 416 pinned, 133 in the DEBT ledger, 2 exempt.(+1 grow-only RETAINED coverage row for the new store suite; the DEBT baseline is untouched)✓ where-matcher conformance holds: 303 matcher(s) discovered, 303 answer the combinator battery correctly or refuse it loudly (190 refuse). 0 silently-wrong and 0 unjudged … none new.OK: 18 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.— this one failed first and changed the design: the "automation no longer registers the ledger" pin was reading across the package boundary, so it moved intoservice-automationand became a behavioural pin over a realinit()instead of a grep.check-nul-bytes: OK (scanned 6869 text file(s) … no raw ASCII control bytes).✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) … none new. baseline key set verified against fe3d74f: no files added.✓ slot-lookup ratchet holds: 107 unswept site(s) … none new.check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured … none above its recorded number. surplus: nonecheck-test-source-alias OK — 72 packages with tests scanned·check-type-source-resolution OK — 93 tsc program(s)·check-i18n-bundles: OK (9 package(s) — all bundles in sync)·check-i18n-stale-fill: OK … 0 stale-fill leaf/leaves·✓ durability-degradation log levels: 29 … all loud·✓ check:plugin-teardown-shape … ⛔ SHRINK-ONLY, baseline fully burned down·✓ kernel hook pin pairing·✓ check:published-files·check-page-declaration-shape: OK·✓ check-empty-changeset/--adr-0087/--changeset-no-major self-tests·OK: all 104 declared cross-package glob(s)·OK check:comment-mask-adoption· spec-liveness family (check:liveness,check:empty-state,check:strictness-ledger,check:variant-docs) all ✓.check:engine-split-ratioinitially refused on the shallow clone (correctly, rather than answering wrongly); re-run aftergit fetch --shallow-since=2026-05-21→ exit 0,ratio: 97.6%.No
packages/specsurface. Nothing undercontent/docs/releases/. Changeset:.changeset/ledger-convergence-registration-and-one-store.md(minor × 4).Generated by Claude Code