Repository navigation
fix(plugin-auth): the admin identity rows on the compliance ledger record the admin's decisions, never a value of a user field (#21174) - #21195
Conversation
…cord the admin's decisions, never a field value of the user The explicit sys_audit_log rows written by the admin create-user and set-user-password endpoints copied values those calls write into the user's fields into free metadata, where the ledger's read-time snapshot narrowing cannot reach them. The row now carries a closed decision set plus its object_name/record_id reference; the values ride the mirror's narrowed snapshot columns. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
… class on a real boot A reader of each field class (masked, capability-gated, not granted) and the read-only wildcard reader are served both explicit admin rows and no value of their class through the list, by-id and projected doors; the unmasking control still receives every class through the mirror's snapshots, and the explicit rows carry only the closed decision set. The audit-trail pin now reads the must-change-password stamp from the mirror's update row. Adds the patch changeset. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ecision type The excess-property check refuses a literal key but not a conditional spread; the unit pins are the guard for that spelling. Measured by the ablation legs: a literal-key leg fails the declaration build, a spread leg builds and is caught by the pins. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 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 1b131e68d4d9b2efecc98b1ab42e60d5668c38ab && git checkout 1b131e68d4d9b2efecc98b1ab42e60d5668c38ab
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0b12b9ea8f36a9d561b385d1d0484966315d6e89 0f3144328128e834f73286906d24992f8221163b && git checkout -B drift-repro 0b12b9ea8f36a9d561b385d1d0484966315d6e89 && git merge --no-ff 0f3144328128e834f73286906d24992f8221163b
node scripts/docs-audit/affected-docs.mjs --json 0b12b9ea8f36a9d561b385d1d0484966315d6e89
|
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 36889082281 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…e policies — double accumulation, the PostgreSQL boolean cast and the empty-sum fold, hoisted into core (objectstack-ai#21042) (objectstack-ai#21209) Fixes objectstack-ai#21042 Clause-②: yes (widening) ## What this changes The analytics native-SQL strategy (`NativeSQLStrategy`, the default on a SQL driver) skipped three aggregate policies that `SqlDriver.aggregate()` applies. So one route answered different numbers, or a `500`, depending on which strategy served it. This PR follows the route ruling on objectstack-ai#21042 (comment `5925613967`): the operand policies are hoisted into `@objectstack/core`, beside `AGGREGATE_ANSWER_KIND`, and both faces read them from there. - **`packages/core/src/utils/aggregate-answer.ts`** (`@objectstack/core`, `minor`). It takes: - `AGGREGATE_ACCUMULATION`, moved from `driver-sql` with its docblock (the docblock and the table are byte-identical to the base, apart from the `export` keyword); - `aggregandColumnClass({ type, multiple })`, the one column-class predicate (`'fractional'`, `'integral'`, `'boolean'`, or none); - `POSTGRES_BOOLEAN_AGGREGAND_CAST`, the objectstack-ai#11635 cast, as a `Record` over `AggregationFunction`: `sum` / `avg` / `min` / `max` are cast, the counts never are; - `doubleAccumulationOperand(operand, dialect)`, the double operand with the dialect as a parameter; - `aggregandOperandSql(func, columnClass, dialect, operand)`, the one composition both faces emit (the cast inside, the double operand around it). No `index.ts` line was added: the module is already exported. - **`packages/drivers/driver-sql/src/sql-driver.ts`** (`patch`), in the ruled regions only. The `AGGREGATE_ACCUMULATION` table becomes a pointer plus an import. `isFractionalNumericType` is deleted. The two registry fills fill `fractionalNumericFields` through the predicate. `accumulatesInDouble` / `doubleAccumulationOperand` are replaced by `aggregandColumnClassOf`, which maps the driver's registries onto the predicate's classes. In `aggregate()`, the private boolean-cast condition and the accumulation call become one `aggregandOperandSql(funcName, class, this.dialectName, '??')`. - **`packages/services/service-analytics/src/strategies/native-sql-strategy.ts`** (`patch`), in two places. - **`resolveMeasureSql`** wraps the column it hands `AGGREGATE_SQL` / `CONDITIONAL_AGGREGATE_SQL` in `aggregandOperandSql`. The column class comes from the declaration the host already relays (`declaredValueShape`), on the object the column lives on (`columnObjectOf`, the one hop resolver). The dialect comes from `sqlDialect`, which the strategy already reads. - **The `execute` shaping point** that PR objectstack-ai#21040 added now folds a `null` measure answer to `emptyGroupValueFor(measure.type)` (`@objectstack/spec`). It does this for every measure, measure-scoped ones included, before the number presenter, in `driver-sql`'s order. The dataset door's `DatasetExecutor` fill stays, and it is idempotent on a folded row. - The one line outside those two regions is the `generateSql` call site, which now passes `ctx` to `resolveMeasureSql`. - No native copy of any policy, and no runtime hook asks the driver: the rejected (C) route was not taken. `canHandle`, `buildFieldMeta`, the hop-object sites, the filter / text-match rendering, `analytics-service.ts` and `field-read-admission.ts` are untouched. ## The card's table, before and after Measured through `AnalyticsService.query` (the cube door, which `POST /api/v1/analytics/query` relays verbatim) and `AnalyticsService.queryDataset` (the dataset door), on `AnalyticsServicePlugin` over a real ObjectQL engine and `SqlDriver`. **Native** is the plugin's own composition (`NativeSQLStrategy` answered, with one raw statement and no engine aggregate). **ObjectQL** is the same composition narrowed to `engine.aggregate`. "Before" is the strategy file at the base `d34aa58a2a`; "after" is this branch at `61aab5013a`. Neither merge since then touches the aggregate code paths, and the pins below are green at `ef1f9d8484`. Fixture: - group `f`: `frac` (a `number` column) holds 0.1 and 0.2, and `flag` holds true and false; - group `i`: `stars` (a `rating` column) holds seven 1s and two 2s, and `flag` holds 7 trues and 2 falses; - group `n`: every aggregand is NULL in all three rows. The measure-scoped measures filter on `tag = x`, which only group `f` holds. **PostgreSQL 16.13** (a private local server; the ObjectQL column is the same before and after): | measure | group | door | native before | native after | ObjectQL | |:--|:--|:--|:--|:--|:--| | `sum(frac)` | f | cube, dataset | `0.3` | `0.30000000000000004` | `0.30000000000000004` | | `avg(frac)` | f | cube, dataset | `0.15` | `0.15000000000000002` | `0.15000000000000002` | | `avg(stars)`, an integer column | i | cube, dataset | `1.222222222222222` | `1.2222222222222223` | `1.2222222222222223` | | `sum(flag)` | i | cube, dataset | `500 DATABASE_ERROR` | `7` | `7` | | `avg(flag)` | i | cube, dataset | `500 DATABASE_ERROR` | `0.7777777777777778` | `0.7777777777777778` | | `min(flag)` / `max(flag)` | i | cube, dataset | `500 DATABASE_ERROR` | `0` / `1` | `0` / `1` | | `sum(frac)`, all-NULL group | n | cube | `null` | `0` | `0` | | `sum(frac)`, all-NULL group | n | dataset | `0` (executor fill) | `0` | `0` | | `sum(flag)`, all-NULL group | n | cube | `500 DATABASE_ERROR` | `0` | `0` | | `avg(frac)`, all-NULL group | n | cube, dataset | `null` | `null` | `null` | | measure-scoped `sum(frac)`, no admitted row | i | cube | `null` | `0` | `0` | | measure-scoped `sum(frac)`, no admitted row | i | dataset | `0` (executor fill) | `0` | `0` | | measure-scoped `avg(frac)`, no admitted row | i | cube | `null` | `null` | `null` | | `count` control | n | cube, dataset | `3` | `3` | `3` | | measure-scoped `count` control | i | cube, dataset | `0` | `0` | `0` | **SQLite** (better-sqlite3): accumulation and the boolean answers already agreed on every face (`0.30000000000000004`, `0.15000000000000002`, `1.2222222222222223`, `7`, `0.7777777777777778`, `0` / `1`). The fold is the policy that diverged there: | measure | group | door | native before | native after | ObjectQL | |:--|:--|:--|:--|:--|:--| | `sum(frac)` / `sum(stars)` / `sum(flag)`, all-NULL group | n | cube | `null` | `0` | `0` | | `sum(frac)` / `sum(stars)` / `sum(flag)`, all-NULL group | n | dataset | `0` (executor fill) | `0` | `0` | | measure-scoped `sum(frac)`, no admitted row | i, n | cube | `null` | `0` | `0` | | measure-scoped `sum(frac)`, no admitted row | i, n | dataset | `0` (executor fill) | `0` | `0` | After the fix, the native and ObjectQL faces **differ in 0 of 144 cells** (2 drivers × 2 doors × 12 measures × 3 groups). **MySQL is NOT MEASURED**: there is no MySQL server in this container. The MySQL operand text is pinned offline: by `core`'s `aggregate-answer.test.ts`, and by the `driver-sql` move proof for the driver's own statements. ## The move proof `driver-sql`'s aggregate statements were dumped at the base, before any consumer changed. The dump covered `SqlDriver.aggregate()` for every function (`count`, `count_distinct`, `sum`, `avg`, `min`, `max`, and `count(*)`), aliased and unaliased, over 23 columns: every fractional, integral and boolean type, the `float` / `integer` / `int` aliases, multi-valued and untyped columns, and text / date / lookup / formula. It ran on SQLite, PostgreSQL and MySQL, through both registration paths (`registerObjectMetadata` and `registerExternalObject`), offline (knex `toSQL()`). The policies were then hoisted, `driver-sql` was switched to the imports, and the same dump was run again: - base dump: 1668 entries, 0 errors, md5 `8eee668372a28a7568f3eb1cc5a2bc9b`; - after dump (at `bc8aa0cc2f`): 1668 entries, md5 `8eee668372a28a7568f3eb1cc5a2bc9b`. `cmp` printed nothing: the two dumps are **byte-identical**. `sql-driver.ts` and `aggregate-answer.ts` are unchanged between `bc8aa0cc2f` and `61aab5013a`. The committed move-proof pin, `packages/drivers/driver-sql/src/sql-driver-21042-aggregate-policy-move.test.ts`, holds the captured expressions for one column of each class, on each dialect and through each registration path. It passed at the base (`192fc0010b`: 54 / 54) and passes after (54 / 54). ## Pins (committed red first, then the fix) | file | at the pins commit (`192fc0010b`, base code) | after | |:--|:--|:--| | `core` `aggregate-answer.test.ts` | 16 red (the exports did not exist) | 22 / 22 | | `service-analytics` `native-sql-aggregate-policies.test.ts` (each measure on both faces at both doors, against the engine's arithmetic; SQLite and live PostgreSQL cells) | SQLite: the cube-door folds red. PostgreSQL: accumulation, boolean `500`, folds red | 49 / 49 | | `service-analytics` `cube-measure-field-type-door.test.ts`, **the lifted skip** | PostgreSQL native `max(boolean)` red (`500`) | 23 / 23 | | `rest` `analytics-dataset-aggregate-policies-door.test.ts` (the route, both strategies) | PostgreSQL: 7 red (accumulation and booleans). SQLite green (that door already folded) | 19 / 19 | | `driver-sql` move proof | 54 / 54 | 54 / 54 | **The lifted skip:** `it.skipIf(cell.id === 'pg' && face === 'native')` in `cube-measure-field-type-door.test.ts` (from PR objectstack-ai#21128) is gone. Its comment now says why the cell runs on every cell and face. The PostgreSQL native `max_flag` cell answers `1`. `native-sql-measure-number-presentation.test.ts` gets a comment-only edit: its header said the native statement does not carry objectstack-ai#20387's accumulation, and it now points at the new pin. ## Ablations There was one ablation per policy, each predicted in writing before it ran. Each mutation was planted through `scripts/ablation-replace.mjs`, which checks that the anchor hit and that the blob changed. Each mutation was confirmed in the built `dist/` (`ablation-dist-preflight.mjs`: marker present). Each restore ran by absolute path (`git checkout HEAD`), and the file's blob was proven equal to its `HEAD` blob with `git diff HEAD` empty. After each restore the package was rebuilt, and the marker was proven absent from `dist/` with the tree clean. Every prediction held exactly. | ablation | mutation | predicted red | observed red | |:--|:--|:--|:--| | A1 accumulation | `core` `accumulatesInDouble`'s dialect gate never admits PostgreSQL or MySQL | `core` 3; `driver-sql` move proof 24 (pg and mysql × both fills × the six numeric / boolean columns); `service-analytics` 10, PostgreSQL only (both doors × `sum` / `avg(frac)`, `avg(stars)`, measure-scoped `sum` / `avg`); `rest` 3, PostgreSQL only | the same 3 / 24 / 10 / 3; SQLite cells green; `avg(flag)` green as predicted | | A2 boolean cast | `core` `aggregandOperandSql` never casts | `core` 1; move proof 4 (pg × both fills × `boolean` / `toggle`); `service-analytics` 8, PostgreSQL only; the lifted cell, PostgreSQL native and ObjectQL, 2; `rest` 4, PostgreSQL only | the same 1 / 4 / 8 / 2 / 4 | | A3 fold | the native shaping point never folds | `service-analytics` 8, cube door only (SQLite and PostgreSQL × the three all-NULL `sum`s and the measure-scoped `sum`); everything else green, the `rest` dataset-door route included, because the executor fill folds there | the same 8; `rest` 19 / 19 green | A1 and A2 show one policy reaching both faces. Each one turned `driver-sql`'s own statements red. Under A2 the ObjectQL face's `max(boolean)` cell failed too, with the driver's refusal. Under A1 the ObjectQL face answered the same exact decimal as the native face for the plain measures: the failing assertion was the engine's number, while native and ObjectQL still agreed. A **reverse type check** also ran. Passing a dialect the new type rejects (`'oracle'`) to `aggregandOperandSql` turned `service-analytics`' typecheck red (`TS2345 ... not assignable to parameter of type 'AggregandSqlDialect'`), which shows the rebuilt `core` `.d.ts` was read. The file was restored byte-identical. ## Verification (at `ef1f9d8484`, after merging `origin/main` at `cb45469e67`, which carries PR objectstack-ai#21170 and PR objectstack-ai#21173) Everything below ran as one locked script, at `ef1f9d8484`, with each exit code captured before any pipe. The live PostgreSQL 16.13 server ran at `timezone = Asia/Shanghai`, and the `driver-sql` suite ran under `TZ=America/New_York`, which are its own non-vacuity preconditions. - **Refresh after the merge:** `pnpm turbo run build --filter='!@objectstack/docs' --concurrency=1` exit 0, and `pnpm --filter @objectstack/spec check:generated` exit 0. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 67 commands from the real diff (10 paths). All 67 exited 0. Reconciliation with `--ran` and the recorded exit codes printed: `Run reconciliation — 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.` - **Typecheck:** `pnpm --filter … typecheck` exit 0 for `@objectstack/core`, `@objectstack/driver-sql`, `@objectstack/service-analytics` and `@objectstack/rest`. - **Tests, every vitest project of every touched package** (`vitest run --maxWorkers=2`, with `OS_TEST_POSTGRES_URL` set so the live cells ran): | package / project | files | tests | |:--|:--|:--| | `core` local | 74 passed | 2120 passed | | `core` repo | 3 passed | 48 passed | | `driver-sql` | 216 passed, 3 skipped | 4300 passed, 96 skipped | | `service-analytics` | 161 passed | 3765 passed | | `rest` local | 256 passed | 5027 passed, 127 skipped | | `rest` repo | 5 passed | 179 passed, 1 skipped | - **The five pin files, run explicitly:** move proof 54 / 54, `core` 22 / 22, policies 49 / 49 (24 SQLite + 24 PostgreSQL + the oracle), field-type door 23 / 23, `rest` route 19 / 19 (9 SQLite + 9 PostgreSQL + the oracle). - **Lint, narrowed and proven:** `eslint --no-inline-config --format json` over the 9 changed code files answered 9 results, 0 errors and 0 warnings. The population is read from eslint's own config: `ESLint.isPathIgnored` answers `false` for each of the 9. The narrowing excludes nothing that could move, because `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), so this diff cannot change the verdict on an untouched file. The repo-wide `pnpm lint` is CI's. - **The `driver-sql` live preconditions:** the first gate run used a private server at UTC, and the suite's four timezone non-vacuity cells failed by design (`… start it with timezone=Asia/Shanghai`). With the server at `Asia/Shanghai` and the process at `America/New_York` the suite is green, as listed above. - **`main` after the final merge:** `origin/main` moved 4 commits past `cb45469e67` before this PR opened (objectstack-ai#21149, objectstack-ai#21188, objectstack-ai#21195, objectstack-ai#21192). None of them touches `core`, `driver-sql`, `service-analytics` or the analytics `rest` tests. The one `packages/spec` file in the analytics area, `ui/dataset.zod.ts`, changes a comment only. They are not merged here; CI runs on the merge ref. ## Acceptance notes - **MySQL is NOT MEASURED** (no server in this container). The MySQL operand text is pinned offline in `core` and in the `driver-sql` move proof. - **The live PostgreSQL cells are not run in CI.** No CI step sets `OS_TEST_POSTGRES_URL` for `service-analytics` or `rest`. The cells above ran against a private PostgreSQL 16.13 started for this run and removed afterwards. In CI the SQLite cells run, and so do the offline `core` / move-proof pins. - **Phase 0's note on the dataset door, which is no divergence:** a measure-scoped `avg` is **absent** from a row its supplementary query reported no row for. That is `x_avg_frac` for groups `i` and `n`, on both strategies and before and after. It is not `null`. The new service pin holds this cell only to "both faces agree", not to a value. Relatedly, a dataset-door selection made only of measure-scoped measures reports only the groups their filter admits, so the pin asks each one beside the base count. - **Residual, as stated in the ruling:** a host that relays no field declarations (`declaredValueShape`), or names no SQL dialect, gets no column class or no policy. It keeps the native arithmetic it had, and a PostgreSQL boolean `sum` there still answers `500`. The plugin's own composition wires both. - **How `driver-sql` reads the class:** it reads its own registries rather than calling the predicate per column. `fractionalNumericFields` is filled by the predicate. `booleanFields` and `numericFields` are filled by the driver's coercion rules, whose populations equal the predicate's `'boolean'` and `'fractional'` ∪ `'integral'` classes. The move proof pins that equality per column class. Asking the predicate per column through the driver's `valueShapeFields` would retire `fractionalNumericFields`, but its declaration and shard-alias regions are outside the ruled surface, so this PR does not do it. - **The scan-order residual is unchanged.** On PostgreSQL and MySQL the double sums are added without compensation (`AGGREGATE_ACCUMULATION`'s docblock), so three or more fractions can still differ in the last place from SQLite and the rows path. The pins use two addends. - objectstack-ai#21129 (the presenter for a relationship-path `min` / `max`) is not addressed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21174
Clause-②: no
The compliance-ledger rows that
plugin-auth's admin identity endpoints write themselves now record the admin's decisions and a reference to the user, never a value of a field of that user. The values those calls write into the user's fields stay where the CRUD mirror already records them: on itscreateandupdaterows for the same writes, in the before/after snapshot columns that PR #21171 narrows per reader. This follows triage's direction on the card (5932908541): the row records the decision or a reference to the record, never a parent field value in free metadata, and there is no read-time mapping of decision names back to fields.Measured first, on a real boot
Re-measured privately on
mainatb9087d77(PR #21171 in). The stack wasbootStackwith the realSecurityPlugin,ObjectQL, SQL driver, REST and auth layers (the admin plugin on), plusAuditPlugin. The seeded platform admin called the create-user and set-user-password doors. Readers read the ledger through the generic list door. Readings stay private to the dispatch; this table gives classes only.metadatametadatametadatametadatametadataThe set-user-password row carried the value of one written field the same way. The data plane answered every reader
404for the created user, the control included (see the acceptance notes).What changed
packages/plugins/plugin-auth/src/admin-user-endpoints.ts:runAdminCreateUserno longer copies the four values it writes into the user's fields into the row'smetadata.runAdminSetUserPasswordno longer copies the one it writes.writeAdminAudittakes a closedAdminAuditDecisionstype: the operation, whether the password was generated, whether the account's address is a generated placeholder, whether the membership was bound, and the bound organization as a reference. Each member is either a decision no field of the user stores, or a reference to another record. The row's reference to the user is its ownobject_nameandrecord_id..changeset/21174-admin-audit-metadata.md:patchfor@objectstack/plugin-auth, with the migration line for a reader that took a value from these rows.The type refuses a field value written as a literal key at compile time. A conditional spread passes TypeScript's excess-property check, so the type does not stop that spelling; the unit pins do. The ablations below measured both.
Census: every non-mirror ledger writer in
domain:servicesEvery
sys_audit_loginsert outside the CRUD mirror, found by grepping the lane's packages (plugin-auth,plugin-security,plugin-sharing,plugin-approvals,plugin-audit,plugin-webhooks,plugin-email,embedder-openai,knowledge-*,services/*,connectors/*,triggers/*) for the ledger's name and for its constants.plugin-sharing,plugin-approvals, the connectors, the triggers and every other service name the ledger in comments only.metadatacarriesplugin-authadmin-user-endpoints.ts#writeAdminAudit, create-usercreateon the userupdateon the userplugin-authadmin-import-users.ts, run-level rowimport,record_idnullplugin-authauth-session-audit.tsevents, written byplugin-auditauth-event-audit.tslogin/logouton the sessionactorcolumn. The client fingerprint rides the ledger's own columns, notmetadataplugin-auditread-audit.tsservice-settingsconfig-change-audit.tsconfig_change,record_idnullplugin-securityplatform-admin-standing-audit.tsplatform_admin_standing_changeplugin-securityis outside this card's file surface and untouchedNo in-lane sibling needed an edit, so no edit outside
plugin-authwas made beyond the tests and the changeset.Downstream readers of the dropped values
packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.tsasserted one dropped value on the explicit create row. That case now asserts the explicit row does not carry it, and that the mirror'supdaterow for the same write does.apps/console/src/pages/system/AuditLogPage.tsx, read at the pinned.objectui-sha) rendersmetadataas pretty-printed JSON. It reads no key by name.Pins
packages/plugins/plugin-auth/src/admin-user-endpoints.test.ts, 4 new cases. For each row it asserts:The cases cover create-user with every optional input, phone-only create, a create that binds a membership, and set-user-password. Each case first checks that the call really wrote those fields.
packages/qa/dogfood/test/admin-ledger-decision-metadata.dogfood.test.ts, 8 cases, on a real boot.beforeAllarms it withassertArmed. The explicit rows exist at rest, and the mirror rows at rest carry every class. The mirror rows served to each reader withhold exactly its class, and the control is served every class.Ablations: every negative pin, put back and shown red
All legs went through
scripts/ablation-replace.mjs. Each anchor hit once and landed by count and blob. Each restore was proven by blob equal to HEAD and an emptygit diff HEAD.The first unit attempt was a no-op. Its replacement text contained its own anchor, so the tool refused before running anything. Every leg was re-spelled and run again.
Unit legs,
src-resolved, over the 41-case file:tsc --noEmitEvery other case stayed green, and the restore run read 41/41.
Dogfood legs, resolved through
dist. Each leg rebuiltplugin-auth, andscripts/ablation-dist-preflight.mjsproved the marker present indist. Three legs used a literal key, and their declaration build failed on the closed type: the JS emitted, the preflight proved it, and the suite ran on it. The spread leg built clean.In every leg the preservation cases and the other classes' cases stayed green. The restore leg rebuilt
plugin-authand proved all four markers absent with--absenton a clean whole tree. It then went 11/11 green.The same 11 cases were also run against the pre-fix build. Red: the 5 negative cases and the audit-trail case. Green: the 3 preservation cases and the 2 untouched audit-trail cases.
Verification at head
0f314432origin/mainwas merged once, with no overlap with this diff; then a reinstall and a rebuild of the dogfood closure.pnpm --filter @objectstack/plugin-auth test: 116 files, 2484 tests passed.pnpm --filter @objectstack/plugin-auth typecheck: exit 0. The test-typecheck debt is held unchanged: 10 files, 94 errors, 23 signatures.vitest --project isolated, 9 files and 59 tests passed:admin-credential-lifecycle,admin-platform-admin-standing,admin-route-nonadmin-refusal,bearer-lane-password-change,membership-reconciler);audit-log-field-valuesandauth-session-audit-trail.dispatch-gates --repo objectstack-ai/objectstack --commandsderived 67 families, with no paths. All 67 were run with exit codes captured before any pipe, and all exited 0.--ranreads 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET (exit 3) because eight unrelated packages had nodist. Those packages were built and it re-ran to exit 0.eslint --no-inline-config --format jsonover the 4 changed TS files reported 4 files, none ignored, 0 errors and 0 warnings.eslint.config.mjsenables no type-aware linting, so no untouched file's verdict can move. The repo-wide lint is CI's.2c1cef33. No governed surface.Acceptance notes
Rows written before this release. They keep the values they copied. The ledger is append-only and the fix is producer-side, so nothing rewrites them, and a read-time narrowing of
metadatais ruled out. Whether those stored rows are scrubbed is a decision for the maintainer. It is reported to the seat.The parent-record gate. Every reader in the pin, the control included, is answered
404by the data plane for the created user, and is still served the ledger rows about it. That is the class of security(plugin-audit): sys_audit_log has no parent-record read gate, so a ledger reader is served the rows about a record the data plane answers 404 to (the ledger's #20833) #21175 (the ledger's parent-record gate), which this seat holds separately. security(plugin-audit): sys_audit_log has no parent-record read gate, so a ledger reader is served the rows about a record the data plane answers 404 to (the ledger's #20833) #21175 remains open and is not addressed here.Judged, not changed:
actorcolumn carries.Each stays.
The fixture's object extension replaces two platform field definitions on the user object.
content/docs/data-modeling/object-extensions.mdxdocuments that replace semantics ("A name the target already has is replaced, not merged"). This is how a masked and a capability-gated class reach those fields on a real deployment; the platform declares neither.Generated by Claude Code