Skip to content

Ledger convergence: registration home + one store implementation + permission-set convergence (ADR-0126 §4/§8, maintainer-ruled bundle) #12159

Description

@os-support-ai

Part of #12150 (Epic: ADR-0126 implementation, v17 line — 17.x minor, maintainer-ruled).
Maintainer ruling, 2026-08-26, live PM chat, verbatim: 「同意」 — to the three-part package presented with four-axis analysis (full ruling record: #12359 comment 5419050253): #12359 option B, #12350, and this card's original L8 scope are one card, one PR, one verification pass. This card is that card; its PR closes #12159, #12350 and #12359.
Dispatch: coordinated under the #12150 program — ⛔ not from the general queue (maintainer anti-preemption instruction, 2026-08-25, PM session session_01KWRU3s15AJz7PGW7a7wdCh). Contract: ADR-0126 §4 walls + §8 items 2–3, ADR-0029 D7 (object ownership), ADR-0068 D2 (write authority unchanged).

Part 1 — registration home (#12359, ruled: option B)

sys_metadata_activation registration moves from the automation service's manifest (registerRunObject, beside sys_automation_run/sys_flow_dispatch) to PlatformObjectsPlugin — where the object is declared, beside SysMigration/SysMigrationJournal/SysSecret. Move, not add: single owner, no double registration. Consequence: every composition carrying platform-objects has the ledger, so packaged-action disable works without the automation service, and every future Regime C consumer inherits it.

Part 2 — one store implementation (#12350)

One metadata_type-parameterized store replaces the two copies of the §4 row contract (ObjectStoreFlowActivationStore in service-automation, ObjectStoreActionActivationStore in objectql). Home: a package BOTH may depend on — @objectstack/core is the leading candidate (#12350's own analysis); ⛔ platform-objects as code home would invert the tiering for objectql (orthogonal to Part 1's registration move, which is composition-level). Both consumers move onto it; the row semantics stay byte-equivalent: install-level rows only, org-carrying rows skipped on read, driver 0 read as false, read-then-write, no engine-slice delete.

  • Both existing pin suites (flow-activation-ledger.test.ts, action-activation.test.ts) stay green unchanged — they pin the contract, so they are the proof the consolidation lost nothing.

Part 3 — permission-set convergence (original L8, ADR-0126 §8 item 3)

Decide and implement how sys_permission_set.active (the #11513 row-state door, permission-set-projection.ts #4669 carve-out) relates to the generic ledger — projection (column stays, ledger reads through) vs migration (rows move, column retired). §4 walls inherited: no definitions in the ledger, no linkage columns.

Acceptance

  • One PR, Closes #12159, Closes #12350, Closes #12359; three parts verifiable independently in the diff.
  • Actions-only boot: flip works end-to-end (Part 1 positive test); automation boot: flows + actions both keep working.
  • Both activation pin suites green unchanged; Studio save of a package-declared permission set forks it into a silent, undiscoverable overlay #11513 suites green unchanged.
  • No packages/spec surface; smooth upgrade (the table already exists in live databases — moving its registrar must be a no-op for existing data).

Refs: #12359 (ruling) · #12350 · ADR-0126 §4/§8 · ADR-0029 D7 · #11513/#4669 · permission-set-projection.ts:1128-1145

Activity

  1. changed the title [-]L8: permission-set convergence onto the generic activation ledger — projection vs migration (ADR-0126 §8.3)[/-] [+]Ledger convergence: registration home + one store implementation + permission-set convergence (ADR-0126 §4/§8, maintainer-ruled bundle)[/+] on Aug 26, 2026
  2. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    CollaboratorAuthor

    Claiming — dispatched under the #12150 program (standing autonomy ruling, maintainer, 2026-08-25, verbatim: 「继续自主完成所有开发」; bundle ruling 2026-08-26, verbatim: 「同意」).


    Generated by Claude Code

  3. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    CollaboratorAuthor

    Part 3 — decision note, and a STOP

    Posted before any Part 3 code, as the card requires. Verdict: stop-and-report. The measurement says the choice has customer-visible consequences either way, which is the card's own trigger for not choosing on the maintainer's behalf. Parts 1 and 2 are implemented and delivered; Part 3 carries no code.

    All numbers below are from a real boot — bootStack(showcaseStack), rows read through the engine under a system context — not from reading source.

    What is actually in sys_permission_set

    total = 17    managed_by = { platform: 8, package: 9 }
                  with package_id = 9      (so 8 of 17 carry NONE)
                  inactive = 0             org-carrying = 0
    indexes    = [ {name, unique:'organization'}, {active}, {package_id} ]
    listViews filtering on `active` = [ 'active', 'inactive' ]
    sys_position   rows = 16, with package_id = 0
    sys_capability rows = 10, with package_id = 2
    sys_metadata_activation: package_id required = true
                             indexes = [ {metadata_type,name, unique:'organization'} ]
    

    And the #11513 remedy path, driven end-to-end through the real data door:

    POST /api/v1/data/sys_permission_set  (clone)      -> 201
      row = { managed_by:'admin', package_id:null, active:true, organization_id:null }
    PATCH /api/v1/data/sys_permission_set/{id} {active:false}  -> 200
    

    Route A — migration (rows move, column retired)

    Customer-visible, and unsound on two independent axes:

    1. Subject mismatch. 8 of 17 rows on a stock boot carry no package_id, and every clone — the exact remedy the packaged-set lock names — adds another (managed_by:'admin', package_id:null, measured above). The ledger's package_id is required: true and its subject is packaged artifacts (ADR-0126 §4). Migration would have to relax a required column or invent package ids for artifacts nobody packaged.
    2. Row-identity mismatch. sys_permission_set is unique:'organization' on name, so two organizations may each hold sales_readonly with different active values (Five more instances of the #8323 class: admin- and user-authored names on tenant-scoped objects still carry installation-wide unique indexes #8554 measured exactly that pair live). The ledger's organization_id is RESERVED and unwritten (§5), and the store SKIPS org-carrying rows 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 cross-tenant leak the ledger exists to retire, arriving from the other direction.
    3. Surfaces bound to the column, each customer-facing: a declared index {fields:['active']}; two Setup list views filtering on it (active / inactive) and a third displaying it; activate_permission_set / deactivate_permission_set PATCHing /api/v1/data/sys_permission_set/{id} with bodyExtra:{active}; clone_permission_set sending active:true on create; the explain panel's deactivated verdict (The explain engine has no contributor state for a DEACTIVATED permission set — it just vanishes, where an expired grant says "held until … — expired" #8714).
    4. Read-path cost. active is read as a COLUMN off rows resolveAuthzContext has already fetched, via the shared isRowActive predicate — zero extra reads on the hot authorization path. A ledger-backed answer inserts a second store into authorization.

    Route B — projection (column stays, ledger reads through)

    Splits into two halves, and neither is the convergence §8 item 3 asks for:

    The family the card does not mention

    sys_position.active (16 rows, 0 packaged) and sys_capability.active (10 rows, 2 packaged) are the same ADR-0049 switch, judged by the same isRowActive predicate. Converging only the permission member splits a three-member family — and the other two are less packaged than this one, so whatever answer fits here has to be argued for all three.

    Four-axis reading

    • Real business need — no measured pull. Zero inactive permission-set rows on a stock boot; the active switch that IS exercised is the Studio save of a package-declared permission set forks it into a silent, undiscoverable overlay #11513 clone/deactivate loop, which works today and is unrelated to packaged-artifact activation.
    • Long-term soundness — the ledger's declared row identity (install-level, packaged, (metadata_type,name)) and this column's (per-org, mostly unpackaged, per-record) are different contracts. Route A bends the ledger's; route B duplicates a question. Neither is contract-first.
    • AI-written-code safety — a second write door for the same bit (route B's write half) is precisely where an AI-authored admin app would flip a set without passing the packaged-set lock. Today there is one door.
    • Startup scope discipline — this is an unpulled convergence over a landed, working surface, on a three-member family the card scopes to one member.

    What I recommend the maintainer decide

    Three options, in the order I would rank them:

    1. Decline the convergence and record why (recommended). Amend ADR-0126 §8 item 3 to say the row-state door stays where permission-set backfill (ADR-0094 D4) 现在 100% 失败:行里的 active 存储列喂进了 #4001 之后严格化的 permission spec #4669 put it, with the measurement above as the ground: the ledger is for packaged artifacts at install scope, and sys_permission_set.active is neither. Costs nothing, closes an open item honestly.
    2. Re-scope it to the family — one card covering permission / position / capability together, starting from "what is active FOR on a catalogue row", and only then asking whether any of it belongs in the ledger.
    3. Route A anyway, accepting a data migration for unpackaged rows, a per-org dimension the ledger has not chartered (§5), and changes to the Setup views and the two lifecycle actions. I do not recommend it on any of the four axes.

    Parts 1 and 2 are in the PR; Part 3 is stopped-reported there and needs no rework to land whichever way this goes.


    Generated by Claude Code

  4. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 12159,
      "status": "done",
      "branch": "claude/issue-12159-ledger-convergence",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12419",
      "premise_still_valid": true,
      "summary": "Parts 1 and 2 implemented; Part 3 is a measured stop-and-report per the card's own instruction. Part 1 (#12359): sys_metadata_activation registration MOVED from the automation service's manifest to PlatformObjectsPlugin. The triage's open question is now measured — a double registration is NOT benign, registerObject throws 'already owned by package', so MOVE was the only available shape. Discharging the smooth-upgrade wall turned up something the ruling did not consider: a manifest is also a ROUTING decision (resolveDatasourceBinding step 4 routes by the owning package's defaultDatasource), so the naive fold would move the table from 'cloud' to the global default driver on any control-plane deployment — rows in one database, reads against another, every disabled artifact silently re-arming. Measured, then closed: the ledger rides its own manifest from the same plugin carrying the automation manifest's scope/namespace/defaultDatasource verbatim, so routing is byte-identical everywhere and the three siblings keep riding the project DB. Part 2 (#12350): the ADR-0126 §4 row contract now has ONE implementation, ObjectStoreMetadataActivationStore(engine, metadataType) in @objectstack/core (the package both consumers already depend on); each consumer keeps its name, one-arg constructor and docs, and fixes only the discriminator. No API break. Both pin suites stay byte-unchanged and green. Part 3: measurement shows customer-visible consequences BOTH ways, so no code was written and the decision note is posted for the maintainer.",
      "tests": "All at head 3ad9030e7 (also the final commit). Repo-wide `pnpm lint` (eslint . --no-inline-config) exit 0 — whole tree, no narrowing. Full suites: @objectstack/core 'Test Files 39 passed (39) · Tests 968 passed (968)'; @objectstack/platform-objects '31 passed (31) · 497 passed (497)'; @objectstack/service-automation '91 passed (91) · 1082 passed (1082)'; @objectstack/objectql '235 passed (235) · 4174 passed (4174)'; @objectstack/runtime activation dispatch+posture '2 passed (2) · 36 passed (36)'. typecheck green for objectql, platform-objects, dogfood, runtime (core and service-automation declare no typecheck script — verified by the echoed script names, not by a zero-match filter). NEW dogfood boots (packaged-activation-ledger-reach.dogfood.test.ts) 'Test Files 1 passed (1) · Tests 11 passed (11)', each describe carrying an anti-vacuity control on whether the automation service is really composed: actions-and-no-automation boot turns #12359's 503 SERVICE_UNAVAILABLE into 200, writes one install-level row (organization_id NULL), refuses dispatch 409 ACTION_DISABLED, and re-enable updates rather than deletes; the automation boot shows exactly one ledger owner, an unchanged datasource binding, both projections hydrating, and no cross-type row contamination. Boot-shape-exposure dogfood subset (meta routes, package-first authoring, registry gate wiring, route-ledger parity, derive-topology) '6 passed (6) · 45 passed (45)'. ABLATION (reverse verification that the two UNCHANGED pin suites really reach the consolidated implementation): removed the org-row skip from packages/core/src/utils/metadata-activation-store.ts via a python anchored replace, confirmed on disk both ways (anchor 1->0, marker 0->1, git diff --stat non-empty), rebuilt @objectstack/core, and confirmed the marker reached dist with `node scripts/ablation-dist-preflight.mjs @objectstack/core ... ` -> 'marker present in 2 built files ... the ablation is live in the artifact the suite consumes'. Both pin suites then went RED on exactly their org-skip assertion: flow 'Tests 1 failed | 28 passed (29) — SKIPS rows carrying an organization_id'; action 'Tests 1 failed | 17 passed (18) — SKIPS a row carrying an organization_id'. Restore leg run and verified with --absent: 'marker absent from all 12 built files'; anchor back in source = 1. A FIRST ablation attempt is declared and NOT quoted: its marker string contained '/', which broke the perl replacement so the marker-confirmation leg failed (the anchor-removal half held and both suites did redden, but that run's colour is not used. The clean re-run above is). GATES: derived with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack`, RE-derived after the diff grew (the scripts/ ledger edit pulled in six more families), and the whole union re-run on the final head 3ad9030e7 — 30 families, all exit 0, plus the 4-family spec-liveness set. Verdict lines quoted from the gates themselves: 'check-engine-double-contract: OK — 416 pinned, 133 in the DEBT ledger, 2 exempt.'; '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.'; '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 in 417.8s ... none above its recorded number. surplus: none'; 'check-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 durability-critical catch seam(s), all loud'; 'check:plugin-teardown-shape: 63 Plugin implementation(s) ... SHRINK-ONLY, baseline fully burned down'; 'kernel hook pin pairing: 4 dispatched kernel:* hook(s)'; 'check:published-files — 69 publishable package(s)'; 'check-page-declaration-shape: OK — 34 page entries'; 'OK: all 104 declared cross-package glob(s)'; 'OK check:comment-mask-adoption — 23 private comment-stripper(s)'; spec-liveness 'all classified (1 closed, 2 open, 4 output, 9 scope)' + strictness-ledger + variant-docs all green. TWO gates went red first and both changed the work rather than being worked around: (1) check:cross-package-test-inputs refused the 'automation no longer registers the ledger' pin, which had been reading the automation plugin's source across the package boundary — it was moved into @objectstack/service-automation and upgraded from a grep over module text to a BEHAVIOURAL pin over a real init() against a recording manifest service, asserting the object list by equality; (2) check:engine-double-contract asked for the new core store suite's double to be recorded — `--write` added exactly one row to engine-double-contract.pinned.json, the GROW-ONLY coverage ledger, and the shrink-only DEBT baseline was not touched. check:engine-split-ratio refused loudly on the shallow clone rather than answering wrongly; after `git fetch --shallow-since=2026-05-21 origin` it exits 0 at 'ratio: 97.6%'.",
      "open_questions": [
        {
          "question": "Part 3 (ADR-0126 §8 item 3): how does sys_permission_set.active relate to the generic ledger — projection or migration? Measured, both routes have customer-visible consequences, so this is not mine to choose. Full note with every number: issue comment 5419380856.",
          "options": [
            "A. Decline the convergence and record why — amend §8 item 3 to say the row-state door stays where #4669 put it. Ground: the ledger is for PACKAGED artifacts at INSTALL scope and sys_permission_set.active is neither. Measured on a real boot: 8 of 17 rows carry no package_id (the ledger's package_id is required:true), and every clone the #11513 lock names as its own remedy adds another (POST /data/sys_permission_set -> 201 with managed_by:'admin', package_id:null; PATCH {active:false} -> 200). Row identity also mismatches: sys_permission_set is unique:'organization' on name, while the ledger's organization_id is reserved, unwritten (§5) and skipped on read — collapsing per-org sets onto one install-level row is the #10243 leak the ledger exists to retire, from the other direction.",
            "B. Re-scope to the whole ADR-0049 family — one card covering permission / position / capability together. sys_position.active (16 rows, 0 packaged) and sys_capability.active (10 rows, 2 packaged) are the SAME switch through the SAME isRowActive predicate, so any answer here has to hold for all three; the card scopes it to one member.",
            "C. Migration anyway — accept a data migration for unpackaged rows, a per-org dimension the ledger has not chartered (§5), and changes to two Setup list views, a declared index, the two lifecycle actions, the clone action and the explain panel's `deactivated` verdict. Not recommended on any of the four axes.",
            "D. Projection — its read-only half changes nothing a customer sees AND delivers no convergence (it adds a second way to ask one question, the exact shape #12350 in this same PR exists to remove); its write-through half IS customer-visible, becoming a second write path to the column that bypasses the #4669 row-state carve-out ordering and the #11513 packaged-set lock pre-pass, which the card forbids."
          ],
          "recommendation": "A, with B as the follow-up card. Four axes: (real business need) no measured pull — zero inactive permission-set rows on a stock boot, and the active switch that IS exercised is the #11513 clone/deactivate loop, which works today and is unrelated to packaged-artifact activation; (long-term soundness) the ledger's declared row identity and this column's are different contracts — A bends the ledger's, D duplicates a question, neither is contract-first; (AI-safety) a second write door for the same bit is exactly where an AI-authored admin app would flip a set without passing the packaged-set lock, and today there is one door; (startup scope) an unpulled convergence over a landed working surface, scoped to one member of a three-member family."
        },
        {
          "question": "Part 1 side-finding the ruling did not consider: the activation ledger's DATASOURCE is carried by whichever manifest registers it. This PR preserves today's routing so the move is a proven no-op, but it does not decide where the ledger should actually live.",
          "options": [
            "A. Leave as shipped — the ledger keeps riding defaultDatasource:'cloud' (the control-plane database) exactly as it did under the automation service's manifest. No data moves; the status quo is preserved rather than endorsed.",
            "B. Move it to the project database alongside sys_migration / sys_migration_journal / sys_secret — arguably where an INSTALL-level, per-deployment configuration ledger belongs, but it is a data migration for every deployment that has rows there, not a manifest edit."
          ],
          "recommendation": "A for now, as shipped: the smooth-upgrade wall on this card required preserving existing data placement, and B is a separate card that must move the rows, not just the declaration. Worth filing if the maintainer wants the ledger on the project plane — I did not file it, because the answer depends on how the control-plane proxy treats an install-level row with a NULL organization_id, which I could not measure from this repo (the `cloud` datasource is defined in the sibling repo and no composition here registers one)."
        }
      ],
      "out_of_scope_findings": []
    }

    Generated by Claude Code

  5. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    CollaboratorAuthor

    Maintainer ruling on Part 3 recorded (2026-08-26, live PM chat, session session_01KWRU3s15AJz7PGW7a7wdCh). Presented with the measured decision note and four-axis analysis of the three routes; the maintainer ruled, verbatim: 「同意」 — to the recommendation:

    Option A — the convergence is declined, and the declination is the decision. sys_permission_set.active stays the authoritative switch for permission sets; the generic sys_metadata_activation ledger governs packaged behavioral artifacts (flows, actions, and future Regime C consumers) only. Grounds, from the measurements on record: the ledger's required package_id cannot hold the growing unpackaged half of the permission-set population (8/17 stock, +1 per sanctioned clone); per-organization uniqueness cannot collapse onto install-level rows without reintroducing cross-org interference; the column carries a bound ecosystem (index, Setup views, lifecycle actions, clone, explain verdict); read-only projection delivers no convergence and write-through projection bypasses the #4669 ordering and the #11513 packaged-set lock.

    Family scope: this ruling applies to the whole ADR-0049 row-switch family — sys_permission_set.active, sys_position.active, sys_capability.active (same isRowActive predicate). Each keeps its own column; none converges onto the ledger.

    Consequences: Part 3 of this card is concluded by ruling (no code); PR #12419 (Parts 1+2) proceeds and its Closes #12159 stands. ADR-0126 §8 item 3 can be annotated settled-declined in a dedicated docs-only PR if the maintainer wants the ADR text updated — the ruling of record lives here either way.


    Generated by Claude Code

  6. os-support-ai commented on Aug 26, 2026

    @os-support-ai
    CollaboratorAuthor

    Maintainer ruling recorded — Part 3 (permission-set convergence): option A, decline-and-record (2026-08-26, live PM chat, session session_01KWRU3s15AJz7PGW7a7wdCh). The Part 3 decision note above (comment 5419380856) was presented to the maintainer with the measurements and a four-axis comparison of the three routes; the maintainer ruled, verbatim: 「同意」 — to the recommendation:

    Also recorded as informational, not ruled: the ledger's datasource home (PR #12419 preserves today's routing verbatim, deliberately); revisit only if real pull appears — moving it later means moving the rows too.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions