Repository navigation
Commit 4049cac
fix(plugin-security): the boot heal converges on duplicated permission-set names, and a refused existence read never inserts (#22214)
Fixes #22169
Clause-②: no
## What was wrong (measured on `main` at `6ed0c0f3e5`)
All three mechanisms the card names were confirmed on current `main`
before any edit, with an in-repo reproduction: a real `ObjectQL` over a
real `SqlDriver` (better-sqlite3, in memory), a real
`ObjectStackProtocolImplementation`, and the per-organization unique
index dropped after schema sync. Dropping it gives the shape of a remote
table built before the index retrofit, the only shape that can hold
duplicates.
1. **The heal did not converge.** `reconcilePermissionSetProjection`
handed each drifted, overlay-less record to `upsertEnvPermissionSet`,
which re-read the record **by name** (first row, lowest id). With 8
declared names × 3 rows, boot 1 wrote 8 rows, logged 24 drift warnings
and left 16 rows drifted. Boot 2, boot 3 and every later boot logged 16
warnings and wrote nothing.
2. **A layered read was discarded on every row.** `getMetaItemLayered`
was awaited for every candidate row before the trust rule decided
whether it needed the answer. While a `SchemaRegistry` is readable, the
rule never uses it. One layered read is **4 statements** on the real
protocol, so the shape above paid 24 × 4 per boot.
3. **A refused existence read inserted a duplicate.**
`bootstrapPlatformAdmin` reads each default set by name through a
`tryFind` that answers `[]` for a refused read, and it inserted on both
"absent" and "refused". The heal's by-name upsert had the same insert
branch. When by-name reads were refused, the unpatched heal inserted 24
rows (24 → 48) and the bootstrap inserted 2 more.
## The change (`@objectstack/plugin-security` only: 2 source files)
- `permission-set-projection.ts`, `reconcilePermissionSetProjection`
heal branch:
- **The heal writes the row it read, by its id, facets only.** It calls
`tryUpdate` with `{ id: row.id, ...permissionSetRowFields(effective) }`,
the same columns the upsert's update leg wrote. Identity, `active`,
`customized` and provenance are not touched. The heal no longer asks an
existence question, so it has no insert branch.
- **The drift line is logged after the write.** It names the row's `id`,
and a refused write is logged as refused instead of "re-projected".
`driftHealed` counts landed writes, now one per drifted row.
- **The layered read is lazy, once per name.** It is asked only when no
`SchemaRegistry` answers, and the answer is cached per name for the
pass. The cache entry is dropped after this pass backfills that name,
because the backfill changes the answer. A later row of that name then
sees the backfilled definition and heals to it, instead of backfilling a
second time.
- `bootstrap-platform-admin.ts`, the default-set seed loop:
- **A refused existence read declines the insert.** `tryFind`'s existing
`onRefusal` observer tells "refused" apart from "empty".
- **The pass says so once.** It writes one `warn` line per pass, not one
per set, naming the sets. The line goes through `reportThroughSink`, the
derivation the sibling seeders' unreadable lines use. When
`admin_full_access` is one of the declined sets, the promotion half
returns `admin_permission_set_unreadable` instead of
`admin_permission_set_missing`. Nothing in the repository branches on
that string; the only reads are doc comments.
- One `patch` changeset for `@objectstack/plugin-security`.
**Not changed (card scope):**
- No existing duplicate is deleted; that is an operator repair.
- `upsertEnvPermissionSet`'s per-item read on the live single-mutation
path is unchanged. A refused read there still inserts.
- No change to `packages/spec`, no new gate.
## Measurement: statements per boot, in-repo reproduction
This was measured with the harness above on the fix at `5ccce70966`
(source unchanged since; later commits touch tests and the changeset
only). Each count is every SQL statement knex emitted during
`reconcilePermissionSetProjection`. The harness was not committed.
| shape | boot | `main` `6ed0c0f3e5` | this PR |
|---|---|---|---|
| 8 names × 3 rows, all drifted | 1 | 148 statements (140 select / 8
update), 24 layered reads, 24 drift warns, 8 healed, **16 still
drifted** | 76 statements (52 select / 24 update), 0 layered reads, 24
drift warns, 24 healed, 0 drifted |
| | 2, 3 | 116 statements, 24 layered reads, **16 drift warns**, 16
still drifted | **4 statements**, 0 layered reads, 0 warns |
| 60 names × 5 rows, all drifted | 1 | 1684 (1624 / 60), 300 layered
reads, 300 warns, 60 healed, 240 still drifted | 904 (604 / 300), 0
layered reads, 300 warns, 300 healed, 0 drifted |
| | 2, 3 | 1444 statements, 300 layered reads, 240 drift warns | **4
statements**, 0 layered reads, 0 warns |
| by-name reads refused, 8 × 3 | heal + bootstrap | heal inserted 24,
bootstrap inserted 2 (24 → 50 rows) | 0 inserts (24 → 24), 1 bootstrap
line |
On the healing boot, each by-id `UPDATE` costs the engine one extra
`SELECT`. That is the 52 − 4 = 48 selects for 24 updates.
⛔ **The hosted timing is not measured here.** The card's cloud
reproduction (cloud's `ArtifactKernelFactory` on the hosted Turso face,
~36 ms per statement, 99.85 s / 170.70 s) cannot be reached from this
session, and no cloud time is claimed. The before/after boot times on
that reproduction belong to the cloud seat.
## Pins (new) and ablations
**Pins:**
- `permission-set-projection-heal-by-id.test.ts` (real engine, spied and
never replaced; index dropped). Five cases:
- A duplicated set heals once, by id, and the next boot logs no drift
warn.
- Control: a single drifted row heals as before.
- No layered read while a registry answers.
- Without a registry, the layered read happens once per name.
- A refused by-name read inserts nothing.
- `bootstrap-platform-admin-refused-existence-read.test.ts`:
- A refused read inserts nothing, logs one line and reports
`admin_permission_set_unreadable`.
- Two controls: an empty answer still seeds, and an existing row is left
alone.
- `permission-set-projection.test.ts` gains one case: a just-backfilled
name is re-read and the second row heals to the backfilled body, with
one `saveMetaItem`, not two.
**Ablations.** Each leg ran on `e41760d89c` through
`scripts/ablation-replace.mjs` in WRAP mode, with the anchor-hit count
and blob change verified on disk. Each restore was proven with blob ==
HEAD and an empty `git diff HEAD`. The subjects are imported by relative
path from `src/`, so no `dist/` leg applies.
| leg | mutation | result |
|---|---|---|
| A1 | heal through the by-name upsert again | 4 red: duplicate heal
wrote `['ps_a']` instead of `['ps_a','ps_b']`; refused read inserted 2;
no-registry case `['ps_a']`; backfill case `driftHealed` 0. The control
and the no-layered-read case stayed green. |
| A2 | eager per-row layered read restored | 3 red: no-layered-read case
saw 6 reads instead of `[]`; once-per-name and backfill read counts were
also red |
| A3 | per-name cache removed | 1 red:
`['member_default','member_default']` instead of one read |
| A4 | post-backfill cache drop removed | 1 red: 2 `saveMetaItem` calls
instead of 1 |
| A5 | bootstrap decline removed | 1 red: 3 inserts instead of `[]`;
both controls stayed green |
A first A2 attempt was a refused no-op: its replacement contained the
anchor, so the anchor count stayed x1 → x1, nothing ran, and the restore
was proven. It was re-run with a falling anchor.
## Verification (HEAD `e41760d89c`)
- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2`: 176 files, 3695 passed, 45 skipped.
- `pnpm --filter @objectstack/plugin-security typecheck` exits 0,
including `check:test-typecheck` OK. `tsc -p tsconfig.test.json
--listFiles` lists all 3 touched test files.
- Gates:
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 66 commands, plus `pnpm
check:durability-log-level`; all exit 0.
- `--ran`: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN.
- The first run, at `af7b0a339f`, had `check:engine-double-contract` and
`check:objectql-double-limit` red on this PR's new test doubles. Both
were fixed in `e41760d89c`: the heal tests spy on the real engine
instead of an object-literal double, and the bootstrap double applies
`limit` by presence after the filter.
- ESLint, narrowed and proven: 5 touched files, 0 errors, 0 warnings,
run with `--no-inline-config --format json`.
- All 5 files are in the population: `--print-config` resolves each, and
no ignored-file warning was emitted.
- The repository config never enables type-aware linting
(`eslint.config.mjs`, no `parserOptions.project`), so this diff cannot
move any untouched file's verdict.
- No plugin `init()`/`start()` or declaration was edited, so
`check:init-service-contract` and `check:startup-registry-verdict` were
not owed.
## Acceptance notes
- **Measured, not fixed (scope fence): the overlay pass leaves a
duplicated overlay-backed name unconverged, silently.**
- Probe: real engine, 2 drifted rows of `member_default` plus an active
env `sys_metadata` overlay for it.
- Boot 1 projected the overlay onto `ps_a` only, and `ps_b` kept its
drifted facets on boots 1, 2 and 3 with 0 warnings. Step 1 resolves one
row per name through `buildExistingByName`, and step 2 skips every
overlay name.
- It costs no boot time and logs nothing. It is display drift only,
because enforcement reads the metadata definition. It is the same
by-name family, outside this card's fence.
- **Reserved by the card for its own card:** `upsertEnvPermissionSet`'s
per-item read on the live single-mutation path still inserts on a
refused read.
- `retirePermissionSetRecord` has the same by-name `LIMIT 1` shape: on a
metadata delete it removes only the first row of a duplicated name.
Read, not exercised.
- #15196's census marks this machinery for
retirement in C3 (#15204, `pm:blocked`, not
scheduled). C3 would delete these paths. That does not change this card.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0e9371f commit 4049cac
6 files changed
Lines changed: 527 additions & 13 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
| 93 | + | |
93 | 94 | | |
94 | 95 | | |
95 | 96 | | |
| |||
778 | 779 | | |
779 | 780 | | |
780 | 781 | | |
| 782 | + | |
| 783 | + | |
| 784 | + | |
781 | 785 | | |
782 | 786 | | |
783 | | - | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
784 | 802 | | |
785 | 803 | | |
786 | 804 | | |
| |||
835 | 853 | | |
836 | 854 | | |
837 | 855 | | |
| 856 | + | |
| 857 | + | |
| 858 | + | |
| 859 | + | |
| 860 | + | |
| 861 | + | |
| 862 | + | |
| 863 | + | |
| 864 | + | |
| 865 | + | |
| 866 | + | |
| 867 | + | |
| 868 | + | |
| 869 | + | |
| 870 | + | |
838 | 871 | | |
839 | 872 | | |
840 | 873 | | |
| |||
880 | 913 | | |
881 | 914 | | |
882 | 915 | | |
883 | | - | |
| 916 | + | |
| 917 | + | |
| 918 | + | |
| 919 | + | |
| 920 | + | |
| 921 | + | |
884 | 922 | | |
885 | 923 | | |
886 | 924 | | |
| |||
0 commit comments