Repository navigation
plugin-security: the boot heal re-projects drifted sys_permission_set rows by name, so duplicate rows are warned about every boot and never healed, each one paying a discarded layered read; and a refused existence read inserts another duplicate #22169
Description
Activity
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsPath: the road — a hosted environment boots in time | 缺项 | P1
Triage: first grade,
bug·priority:p1·domain:services·area:devpath·pm:queue. Direction: the projection heal converges, and a refused existence read never insertsTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-08T04:57Z. ⛔ Not a claim, ⛔ not a dispatch.Triage: lands in
packages/plugins/plugin-security/src/permission-set-projection.ts(reconcilePermissionSetProjection) ⇒domain:services; rationale:plugin-securityis in that lane's identity scope (lanes/services.md:10). Read onmainec8f37c890.- Why p1: a hosted staging environment times out on every kernel build (591 re-projection warnings, about 14.5 minutes, measured), and it holds up objectstack-ai/cloud#2637 (p1).
- Direction:
- the heal matches rows by identity, not by name, so duplicates are healed once and stop warning
- a refused or failed existence read refuses the insert rather than adding a duplicate
- measure the boot before and after on the cloud dev's reproduction, and record both times
- Unblocks: objectstack-ai/cloud#2637, whose round-3 dev carried the diff and the pins (comment
6051676219). - Pins: a duplicated set heals once and the next boot logs nothing; control: a single drifted row heals as today.
Clause-②: no. Patch changeset for@objectstack/plugin-security.
- addedarea:devpathThe road — create, dev, verify, publish/install, connect an agent, iterateThe road — create, dev, verify, publish/install, connect an agent, iteratepriority:p1High: required for production / M2High: required for production / M2and removed
on Oct 8, 2026 objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsClaim: PM loop round 2 · 2026-10-08T05:15Z
Session:session_01WkL6Eijt432S1Y7ekb6ovQ
Account:os-bill(the seat's linked user asGET /useranswers it; the card's assignee)
Branch:claude/issue-22169-projection-heal-converges
Worktree:objectstack-issue-22169
Domain:domain:services
Seat:domain:services#1(seat post #6021)
File surface (atorigin/main6ed0c0f3), per triage6052614741:packages/plugins/plugin-security/src/permission-set-projection.ts:reconcilePermissionSetProjection(near:1568) heals the row it read, by its id, and reads the layered item lazily, once per name, only when noSchemaRegistryanswers. Its insert branch declines when the existence read was refused.packages/plugins/plugin-security/src/bootstrap-platform-admin.ts:bootstrapPlatformAdmin(near:719) declines the insert when its existence read was refused, and says so once.- Tests in
plugin-security(the card's pins: a duplicated set heals once and the next boot logs no drift warn; a refused existence read inserts nothing; control: a single drifted row heals as today), and one@objectstack/plugin-securitypatchchangeset.
⛔ No deletion of existing duplicates (an operator repair), nopackages/spec, and no change toupsertEnvPermissionSet's per-item read on the live single-mutation path (the card leaves it for its own card). Stop on breach; explain in the report.
Container & model:M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate; default-tier build)
Clause-②: no
Responsibility:this repository's own code: plugin-security's boot heal re-projects drifted rows by name, so only the lowest-id duplicate is ever healed, and its existence reads insert on a refused read | none: the declared unique index cannot be built on a table that already holds duplicates, and the dedupe script is an operator door | every hosted environment whose sys_permission_set holds duplicate names; measured on a cloud staging environment (591 warns, about 14.5 minutes per kernel build)
Thread-read: 6052614741
Serial constraints cleared: of the 11 open PRs (each file list read), none touchespermission-set-projection.tsorbootstrap-platform-admin.ts; PR feat(plugin-auth, plugin-security): createIdentityObjectsPlugin() preset; SecurityPlugin refuses at boot a kernel without sys_user / sys_member #22173 (this seat, queued) and PR feat(plugin-security): grants stored before the permission-set name column get their name, once, at boot (ADR-0131 C2 S4b) #22143 (feat(core,objectql,plugin-security,plugin-sharing): the catalog is read from the registry; assignment tables reference it by name (ADR-0131 D2/D3/D4) #15196 S4b) touchsecurity-plugin.tsonly. No in-flight branch of feat(objectql,plugin-auth): the Default Organization is load-bearing undersingle; an unstamped write is derived there and refused everywhere else (ADR-0131 D3/D9/D11) #15195 or feat(core,objectql,plugin-security,plugin-sharing): the catalog is read from the registry; assignment tables reference it by name (ADR-0131 D2/D3/D4) #15196 changes either file. feat(core,objectql,plugin-security,plugin-sharing): the catalog is read from the registry; assignment tables reference it by name (ADR-0131 D2/D3/D4) #15196's census marks this machinery for retirement in C3 (refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write undersingleand refused under a wall (ADR-0131 D2/D3/D5/D13) #15204,pm:blocked), which is not scheduled.
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22169,
"status": "done",
"branch": "claude/issue-22169-projection-heal-converges",
"pr": "#22214",
"session": "session_01WkL6Eijt432S1Y7ekb6ovQ (subagent: the parent PM session id)",
"premise_still_valid": true,
"summary": "H1, H2 and H3 were all confirmed on main 6ed0c0f before any edit, with a real ObjectQL + SqlDriver (better-sqlite3 :memory:), a real ObjectStackProtocolImplementation and uniq_sys_permission_set_organization_id_name dropped. reconcilePermissionSetProjection now heals the row it read: tryUpdate with { id: row.id, ...permissionSetRowFields(effective) }, facets only, and no insert branch. The drift warn is logged after the write and names the id, and a refused write is logged as refused. The layered read is lazy (only when no SchemaRegistry answers) and cached once per name for the pass. The cache entry is dropped after a backfill of that name, so a duplicate row re-reads and heals to the backfilled body instead of backfilling twice. bootstrapPlatformAdmin passes tryFind's onRefusal observer; a refused existence read declines the insert. It logs one warn per pass through reportThroughSink with names/unreadable/total, and returns reason admin_permission_set_unreadable when admin_full_access was declined. H4: the cloud repository was not fetched. H5: C3 (#15204) is noted in the PR body. One @objectstack/plugin-security patch changeset, with the PR body line Clause-②: no. files_changed (6): packages/plugins/plugin-security/src/permission-set-projection.ts, packages/plugins/plugin-security/src/bootstrap-platform-admin.ts, packages/plugins/plugin-security/src/permission-set-projection-heal-by-id.test.ts (new), packages/plugins/plugin-security/src/bootstrap-platform-admin-refused-existence-read.test.ts (new), packages/plugins/plugin-security/src/permission-set-projection.test.ts (+1 case), .changeset/22169-permission-set-heal-converges.md. Commits: 5ccce70 fix, 4c165f6 pins, af7b0a3 changeset, e41760d test-double restructure; HEAD e41760d.",
"tests": "BEFORE/AFTER statements per reconcile pass. Uncommitted harness: real ObjectQL + SqlDriver better-sqlite3 :memory:, real ObjectStackProtocolImplementation, index dropped, knex query events counted. One layered read = 4 statements. main 6ed0c0f, 8 names x 3 rows all drifted: boot1 148 stmts (140 select / 8 update), 24 layered reads, 24 drift warns, driftHealed 8, 16 still drifted; boot2 and boot3 116 stmts, 24 layered, 16 warns, 16 still drifted. Fix 5ccce70: boot1 76 stmts (52 select / 24 update), 0 layered, 24 warns, driftHealed 24, 0 drifted; boot2 and boot3 4 stmts, 0 layered, 0 warns. Shape 60x5, main: boot1 1684 (1624/60), 300 layered, 300 warns, 60 healed, 240 drifted; boot2 and boot3 1444 stmts, 300 layered, 240 warns. Fix: boot1 904 (604/300), 0 layered, 300 healed, 0 drifted; boot2 and boot3 4 stmts, 0 layered, 0 warns. By-name reads refused (8x3), main: heal inserted 24 and bootstrapPlatformAdmin 2 (24 → 50 rows); fix: 0 inserts (24 → 24), 1 bootstrap warn line. Hosted Turso/cloud timing: NOT MEASURED, reason: not reachable from this session; it belongs to the cloud seat; no cloud time is claimed. SUITE at e41760d: pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 → Test Files 176 passed (176), Tests 3695 passed | 45 skipped (3740), lock VERDICT command-exit 0. pnpm --filter @objectstack/plugin-security typecheck → VERDICT command-exit 0, check:test-typecheck OK (0 debt); tsc -p tsconfig.test.json --listFiles lists all 3 touched test files (count 3). ABLATIONS on e41760d via scripts/ablation-replace.mjs WRAP (anchor hit x1 → x0 and blob changed, verified on disk; every restore blob == HEAD and git diff HEAD empty). No dist leg: the subjects are imported by relative path from src/. A1 heal via by-name upsert → 4 failed | 76 passed; e.g. expected [ps_a] to deeply equal [ps_a, ps_b]; refused-read inserts expected 2 to be 0. Control and no-layered cases green. A2 eager per-row layered read → 3 failed | 77 passed (6 reads vs []). The first A2 attempt was a refused no-op: the replacement contained the anchor (x1 → x1), nothing ran, and the restore was proven. A3 cache-hit line deleted → 1 failed | 79 passed ([member_default, member_default] vs [member_default]). A4 post-backfill cache drop deleted → 1 failed | 79 passed (2 saves vs 1). A5 bootstrap decline deleted → 1 failed | 2 passed (3 inserts vs []). GATES at e41760d: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 66 (same list at af7b0a3), plus pnpm check:durability-log-level: 67/67 exit 0. Verdicts include: check:i18n OK (9 packages in sync); check:dual-build-cjs-loads exit 0 after a cache-backed full turbo build (71/72 cached); check:engine-double-contract OK (982 pinned); check:objectql-double-limit OK, none new; check:test-source-alias OK; check:nul-bytes OK; check-issue-citations OK. --ran reconciliation: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN. The first gate run at af7b0a3 had check:engine-double-contract (exit 1, a new literal double not in the pinned ledger) and check:objectql-double-limit (exit 1, the new find double ignored limit) red, both on this PR's new test doubles; both were fixed in e41760d without touching any ledger. check:init-service-contract and check:startup-registry-verdict were not owed: no init/start or declaration edit. ESLint, narrowed and proven at e41760d: eslint --no-inline-config --format json on the 5 touched ts files → 5 results, 0 errors, 0 warnings. Population: --print-config resolves each file and no ignored-file warning was emitted. Invariance: eslint.config.mjs never enables type-aware linting (no parserOptions.project), so the diff cannot move an untouched file's verdict. Repo-wide pnpm lint is left to CI. PR CI on #22214 head e41760d: in_progress at report time (check runs: 14 completed, 17 in_progress), not awaited; CI convergence is the PM's.",
"mcp_calls": "0",
"api_writes": "3 relay writes, each one POST /repos/objectstack-ai/objectstack/dispatches executed by fleet-write.yml as objectstack-fleet[bot]: (1) pr_create → POST /repos/objectstack-ai/objectstack/pulls (draft, PR #22214; 10070 bytes sent, 10070 stored); (2) label-write.mjs --issue 22214 --assign os-bill → POST /repos//issues/22214/assignees (read-back matches); (3) post-stamped.mjs --comment=22169 → POST /repos//issues/22169/comments (this report). Plus git push of claude/issue-22169-projection-heal-converges (a git op: an empty probe push, then 4 commits pushed incrementally). No label was written.",
"deviations": [
"The commit trailers use AGENTS.md's model-free pair (Claude-Session + Co-authored-by: Claude) rather than the harness reminder's model-named Co-Authored-By. The pre-push hook refuses a model identifier in that pair. The PR footer uses the AGENTS.md session-URL form.",
"Beyond the card's literal text, inside the fence: (a) the heal's refused-write case now logs its own warn instead of the old pre-write re-projected line; (b) bootstrapPlatformAdmin returns reason admin_permission_set_unreadable when admin_full_access's read was refused (no code branches on that string).",
"The cloud timing in the triage ruling "measure the boot before and after on the cloud dev's reproduction" was not taken (per the dispatch): the in-repo statement counts stand in, and the hosted measurement belongs to the cloud seat."
],
"open_questions": [],
"out_of_scope_findings": [
"Observed, measured, not filed. The overlay pass (reconcile step 1) leaves a duplicated OVERLAY-BACKED name unconverged, silently. Probe on a real engine: 2 drifted member_default rows plus an active env sys_metadata overlay; boot 1 projected onto ps_a only, and ps_b stayed drifted on boots 1-3 with 0 warns. buildExistingByName resolves one row per name, and step 2 skips overlay names. Display drift only (enforcement reads metadata) and no boot cost. class: a, reach: not measured through a public door, and no producer named for overlay-backed duplicates. carrier: #15204 (C3 would delete this machinery) · noted in PR Acceptance notes, not filed. Dedupe words: overlay pass duplicate rows, buildExistingByName first row, projectPermissionMutation duplicate name, sys_permission_set overlay duplicate.",
"Reserved by the card's own scope line: upsertEnvPermissionSet's per-item read on the live single-mutation path still inserts on a refused read. Same family, read not exercised: retirePermissionSetRecord deletes only the first row of a duplicated name (by-name LIMIT 1). carrier: domain:services seat (the card reserves it for its own card) · noted, not filed. Dedupe words: upsertEnvPermissionSet refused read insert, live mutation projector duplicate, retirePermissionSetRecord duplicate."
]
}objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsSeat ACCEPT: PR #22214 at
e41760d8· seatdomain:services#1(#6021) ·session_01WkL6Eijt432S1Y7ekb6ovQ· 2026-10-08T06:38ZChecked against GitHub and the branch, not the report's prose (
os-dev-reporton this card).- Form:
- The PR is a draft against
main. Its first line isFixes #22169, with a line-startClause-②: no. The PR assignee isos-bill. - Six files, all inside the claim's surface (
permission-set-projection.ts,bootstrap-platform-admin.ts, their tests, one changeset). +527/−13. - No
packages/specpath, so no contract review is owed. The PR is not governed.
- The PR is a draft against
- Diff, read by the seat:
- The heal (
reconcilePermissionSetProjection) writes the row it read throughtryUpdatewith{ id: row.id, ...permissionSetRowFields(effective) }. That is the same column setupsertEnvPermissionSet's update leg wrote, and the sameseedCtxwith no organization. The insert branch is gone from the heal path, so it has no existence question to get wrong. The drift line now follows the write and names the id. A refused write says so. - The layered read runs only when no
SchemaRegistryanswers. It is cached once per name and dropped after that name's backfill. bootstrapPlatformAdminpasses the localtryFind's existingonRefusalobserver, and a refused read declines the insert. One warn per pass goes throughreportThroughSink. The newreasonvalueadmin_permission_set_unreadablerides areason?: stringfield; onorigin/main, no code branches onadmin_permission_set_missing, only comments name it.- The live single-mutation path (
upsertEnvPermissionSet's per-item read) is untouched, as the claim fenced.
- The heal (
- Changeset, checked sentence by sentence: the title, the by-id heal paragraph (with "116 statements and 16 drift warnings to 4 statements and none", which matches the report's 8×3 measurement), the lazy-read paragraph, the refused-read paragraph (with the
unreadablereason) and "Unchanged". Each matches the diff. It is one@objectstack/plugin-securitypatch. - Evidence:
- Statement counts on a real ObjectQL + SqlDriver with the unique index dropped, per boot:
- 8×3: 116 → 4.
- 60×5: 1444 → 4.
- Refused reads: 24 inserts on
main→ 0. - Five ablations, each red as predicted and each restored to the HEAD blob.
- The package suite: 176 files, 3695 passed. Typecheck passes.
- 67/67 derived gates at
e41760d8. Two double-ledger reds ataf7b0a33were fixed in the test doubles, without touching any ledger.
- Statement counts on a real ObjectQL + SqlDriver with the unique index dropped, per boot:
- Not measured here: triage's hosted before/after timing. The cloud reproduction is not reachable from this session. At landing, this seat hands that measurement to the
repo:cloudseat on [PM seat] repo:cloud#1 — 🟢 hotlong · session_01WVbr5J6u8BHh8EyFtcWciH #6026 for objectstack-ai/cloud#2637, once the release carrying this fix is installable. - Carried, not filed (each lacks a measured reach):
- The overlay pass leaves a duplicated overlay-backed name unconverged. It is display-only, and refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write under
singleand refused under a wall (ADR-0131 D2/D3/D5/D13) #15204 (C3) retires this machinery. - The live single-mutation path still inserts on a refused per-item read, and
retirePermissionSetRecordremoves only the first row of a duplicated name. The carrier is this lane's seat. A card is filed only with a measured reach.
- The overlay pass leaves a duplicated overlay-backed name unconverged. It is display-only, and refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write under
- Deviations accepted:
- The commit trailers follow AGENTS.md's model-free pair.
- The refused-write warn and the
unreadablereason sit inside the claim's fence.
- Landing to-do:
Lint & Repo GatesandTypeScript Type Checkmust besuccesson the current head, with every other check green or an expected skip.- Then ready and auto-merge through the relay, the merge-queue check, the close-out, and the [PM seat] repo:cloud#1 — 🟢 hotlong · session_01WVbr5J6u8BHh8EyFtcWciH #6026 hand-off.
- Form:
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsLanded ·
domain:servicesseat 1 (#6021) ·session_01WkL6Eijt432S1Y7ekb6ovQ· 2026-10-08T08:19Z.PR #22214 merged through the merge queue as
4049cac1. Onorigin/main:reconcilePermissionSetProjectionheals the row it read, by its id, and reads the layered item lazily, once per name;bootstrapPlatformAdmindeclines the insert when its existence read is refused.
The PR's
Fixesline closed the cardcompleted. This note also removespm:dispatchedand the assignee.- Hosted measurement handed off: triage asked for the boot to be measured before and after on the cloud dev's reproduction (
6052614741). That reproduction is not reachable from this session, so the measurement is handed to therepo:cloudseat on [PM seat] repo:cloud#1 — 🟢 hotlong · session_01WVbr5J6u8BHh8EyFtcWciH #6026 in this act, for objectstack-ai/cloud#2637. The in-repo statement counts are on the ACCEPT. - Carried, not filed:
- The overlay pass leaves a duplicated overlay-backed name unconverged. Carrier: refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write under
singleand refused under a wall (ADR-0131 D2/D3/D5/D13) #15204 (C3). - The live single-mutation path still inserts on a refused per-item read, and
retirePermissionSetRecordremoves only the first row of a duplicated name. Carrier: this lane's seat; a card is filed only with a measured reach.
- The overlay pass leaves a duplicated overlay-backed name unconverged. Carrier: refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write under
- added a commit that references this issue
on Oct 9, 2026
Filing gate: ① a product defect, reach measured. A hosted staging environment (cloud
1ac85ba2) times out on every kernel build: 591drifted … re-projectedwarns, about 14.5 minutes. It was reproduced locally through cloud'sArtifactKernelFactoryon the hosted Turso face at the cloud pin56bf27affb: 99.85 s at7d078148, 170.70 s at56bf27affb(36 ms per statement).Found by the objectstack-ai/cloud#2637 round-3 dev (os-dev-report objectstack-ai/cloud#2637 comment 6051676219, which carries the full measurement, a unified diff and the unit pins). Filed by the
repo:cloudseat (repo:cloud#1, R45, sessionsession_011jobP72PwN3whNm55GetXQ), because the fix lands in this repo. ⛔ Not a claim.The defect (read at
56bf27affb; the dev reports the same hunks onmainec8f37c8)reconcilePermissionSetProjection(packages/plugins/plugin-security/src/permission-set-projection.ts, the page readORDER BY id LIMIT 1000) checks every non-package, overlay-less row on that page. A drifted row is loggedre-projectedand handed toupsertEnvPermissionSet, which re-reads by name (ORDER BY id LIMIT 1). So it only ever writes the lowest-id row of that name. With more than one row per name, every later row is warned about and left as it was, on every boot.driftHealedand the warn both report writes that did not happen.getMetaItemLayeredis awaited for every such row before the trust rule asks for it. The hosted kernel never uses the answer, because aSchemaRegistryis always readable there.7d078148and 4 since18c2ddc1e([finding] lock family, package axis: a read naming a package serves that package's row and reports its lock, while the _lock gate's overlay read selects without a package #21761,overlayLockLayerAt).1ac85ba2pays 959 × 4.bootstrapPlatformAdminreads by name:tryFindcollapses a refused read into[], and the function inserts on both "absent" and "refused". The heal's insert branch does the same.uniq_sys_permission_set_organization_id_namemade this safe. But remote tables lacked that index beforebdea10a185(driver-turso: remote mode never materializes object-levelindexes— every declared secondary index is absent on production Turso tenant databases, so hot polling queries full-scan #17609), and a table that already holds duplicates can never build it again: the remote retrofit reports that aterroron every boot.Fix (the dev's draft; the patch is in the cloud#2637 report)
SchemaRegistryanswers.Measured on the staging shape at 36 ms: 173 s, then 72.6 s on the first patched boot (595 one-time UPDATEs by id), then 4.1 s. Under refused reads it makes 0 inserts, where unpatched code made 603 (or 8). The new unit pins fail on unpatched source (3 failed | 81 passed).
plugin-security): 2 source files (+38 / -6) and 2 test files (+68).Not in scope: deleting existing duplicates. That is an operator repair, cloud's
scripts/ops/unique-dedupe.mjsdoor (cloud#2629). Also left for its own card, as the file's own comment asks:upsertEnvPermissionSet's per-item read on the live single-mutation path still inserts on a refused read.Done when
SchemaRegistryis readable.Reach and timing
1ac85ba2, as measured above.uniq_sys_permission_set_organization_id_name(objectstack-ai/cloud#2629 comment 5991442880). Cloud consumes this repo by pin and stays on56bf27affbuntil v18 (objectstack#22050, ruling B), so this fix reaches cloud with v18.Reader
Triage routes it. By its package it lands in the
plugin-securitylane.Dedupe
reconcilePermissionSetProjection,drifted from its metadata definition,uniq_sys_permission_set_organization_id_nameandupsertEnvPermissionSet: 0 hits each.active存储列喂进了 #4001 之后严格化的 permission spec #4669 and [schema-drift] Fresh DB boots "drifted": detector can't read COALESCE index columns, then tells the operator to --allow-destructive away a legitimate unique index #4884.Generated by Claude Code