Repository navigation
fix(security): close out the stored-metadata-body family — project or refuse every further read/copy/evaluate exit - #21144
Conversation
…y read exit Promote the family-wide primitives for the stored-metadata-body security invariant into `@objectstack/spec/kernel`, beside the per-type redactor registry they build on: `STORED_METADATA_BODY_OBJECTS` / `isStoredMetadataBodyObject`, and `redactStoredMetadataRow` / `redactStoredMetadataRows` / `redactStoredMetadataBody`, which project a stored row's `metadata` body through the one `getMetadataTypeRedactor` definition. They live here, not in `@objectstack/metadata-protocol`, for the same reason the registry does: the surfaces that must consult them (service-analytics, plugin-audit, the objectql engine) are packages that do not depend on metadata-protocol but all import `@objectstack/spec/kernel`. One definition of what a credential is, applied at every exit, never a second rule set. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7
… audit, analytics and realtime exits Three further read/copy/evaluate exits for a stored metadata body (a datasource body's credential material included) now go through the one shared seam: - plugin-audit: the audit writer copies a sys_metadata / sys_metadata_history row into sys_audit_log.new_value/old_value and sys_activity.metadata at write time; the copied body is now projected through the shared redactor before it is recorded, and a one-off migration (os migrate audit-metadata-bodies) rewrites the cleartext copies already at rest. Fail-closed: a body the redactor cannot judge is dropped from the recorded copy. - service-analytics: a query naming the stored body column of these objects as a dimension, measure, filter or sort is refused (INVALID_FIELD / 400) at the service door, before any strategy runs — the posture analytics already takes for a member it will not evaluate. - objectql: a data.record.* realtime event projects its after/changes body through the same redactor, so a subscriber to these objects' events receives no credential. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7
…ored-metadata-body-family
… body column, and reconcile the shared seam Builds on the merged #21086 seam. Two parts: - F3 (maintainer ruling A): the generic data door refuses a filter or sort on the stored body column of sys_metadata / sys_metadata_history, the sibling of its groupBy refusal — a predicate on the body evaluates it row by row (a withheld credential is otherwise recoverable by probing) and a sort orders by the same stored bytes, so neither is evaluated. Same family, shape and code (INVALID_FIELD / 400). Aggregation per-measure filters are covered too. - Seam reconciliation: the family's object set, its predicate, the column names and the body redactor now have ONE definition in @objectstack/spec/kernel; metadata-protocol's data-door wrappers (projection, dropType, the grouping and filter/sort refusals) consume it instead of a private copy, so the audit, analytics and realtime exits cannot drift from the data door about what a credential is. Adds the family enumeration pin and the per-surface tests (audit writer and migration, analytics refusal, realtime event, data-door filter/sort). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7
…st's engine double
Gate follow-through:
- The audit-metadata-body migration runner queried with a `{ filters: [...] }`
bag the engine does not read; it now uses the canonical `{ where: { ... } }`
the engine folds (object_name `$in`, and the per-record type lookup by id), so
the rewrite actually selects rows against a real engine.
- The migration test's fake engine opens `findOne` with the producer's own
`assertEngineFindOnePredicate`, and the engine-double-contract ledger records
the new pinned double.
- cli's vitest config aliases `@objectstack/plugin-audit` to source, since the
data-migration-plugins util it now reaches is imported by a cli test
(check:test-source-alias).
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7
📓 Docs Drift CheckThis PR changes 6 package(s): 56 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 146 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 05a6397133583889cc6ed84e2813896912e0cc7c && git checkout 05a6397133583889cc6ed84e2813896912e0cc7c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f0cc16e8d55b02269c7db708254e2917ba7e84e2 5397aa8ba8d301d6e0261f611af1c83f2ed5e6b3 && git checkout -B drift-repro f0cc16e8d55b02269c7db708254e2917ba7e84e2 && git merge --no-ff 5397aa8ba8d301d6e0261f611af1c83f2ed5e6b3
node scripts/docs-audit/affected-docs.mjs --json f0cc16e8d55b02269c7db708254e2917ba7e84e2
|
The migrate command erased the `objectql` service lookup to `any` (no-restricted-syntax in CI's ESLint). It now states the slot's contract, `IObjectQLEngine`, and the runner's engine surface is the real contract's members (`Pick<IDataEngine, 'find' | 'findOne' | 'update'>`) instead of a hand-declared interface. Typing it against the contract exposed a real call-shape defect the erasure had hidden: the runner called `update(object, id, patch, options)`, while the engine's single-id update is `update(object, data, options)` with the row named by `data.id` — the old call would have been refused at the engine's option gate for every row. Fixed, the test double now matches the contract's shape, and the apply pin asserts each write names its row through `data.id`. Also: the object names for the runner's `$in` filter come from the shared `STORED_METADATA_BODY_OBJECTS` set rather than a re-typed list, and a table the run cannot read is counted as a failure (non-zero exit) instead of a silent skip that would report "nothing to rewrite" for rows never examined. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7
…ored-metadata-body-family
…and re-measure the tenant-audit census - The migration test's fake engine now opens `update()` with the producer's own `assertEngineUpdateDispatch`, beside the `findOne` predicate, and the engine-double-contract ledger records the pinned double. - The audit-body rewrite adds one engine write call site (a system-context single-id `update` whose object name is the loop's table), so the tenant-audit census is regenerated (`tenant-audit-census.mjs --write`) and the page's hand-written figures restated from it: 219 sites, 144 decidable / 75 undecidable, 100 decidably elevated. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7
…ored-metadata-body-family
…ch predicate as a literal The data-engine contract's update-options interface carries no index signature, so the predicate's indexed input refused it at the test-layer typecheck. Every option key is passed through unchanged, spread into a literal. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7
…ored-metadata-body-family # Conflicts: # packages/plugins/plugin-audit/src/audit-writers.ts
…amedRead union The field-level read gate now represents a member that names no field (an authored expression) as its own NamedRead arm and refuses it there. The stored-metadata-body refusal takes that union, judges attributable fields only, and leaves expression members to the field gate's own refusal — one rule for them, not a second one here. Pinned with an expression case. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7
|
Landing note: this is a security-boundary change (human floor), so the PR stayed draft until the maintainer decided. The maintainer pre-authorized landing once CI was green, selecting verbatim 「绿了就转 ready + 进队列(推荐)」 in Claude Code session Generated by Claude Code |
…ject an engine middleware is registered for, so its read gates apply (objectstack-ai#21170) Fixes objectstack-ai#21080 Clause-②: yes (narrowing) `yes`: one optional member widens a published contract interface (`IObjectQLEngine.hasObjectMiddleware?`), and `ObjectQL` and `AnalyticsServiceConfig` each gain one additive member. `(narrowing)`: a query the native path served is now refused when it reads a gated object and the ObjectQL strategy cannot serve it (measured below). The changeset carries the BREAKING banner and the ADR-0087 marker. ## What changed Triage's ruling (`5925681388`) is implemented as written. There is no per-object list in `service-analytics`, and no gate registers twice. The middleware chain, `registerMiddleware` and `executeWithMiddleware` are unchanged. - **`@objectstack/objectql`.** `ObjectQL.hasObjectMiddleware(objectName)` sits beside `registerMiddleware`. It reads what `registerMiddleware` already records: `true` when a registration's `object` names the object. A global registration (no `object`, or `'*'`) is keyed to no object and is not counted. It runs and registers nothing. - **`@objectstack/spec`.** The matching optional member on `IObjectQLEngine`, as a declaration and docblock only. - **`@objectstack/service-analytics`.** - One context hook, `DatasetScopedStrategyContext.hasObjectMiddleware` in `strategies/types.ts`. - `AnalyticsService` passes it through from a new `AnalyticsServiceConfig.hasObjectMiddleware` at the `baseCtx` construction site. The plugin wires that config member from the data engine (`DataEngineLike` gains the member). - `NativeSQLStrategy.canHandle` declines a query that reads such an object. The objects it checks are the set the door admitted and scoped (`readScopedObjects`: the base object, declared joins and relationship-path objects). For a context built without that set, it checks the cube's own objects. The declined query routes to the ObjectQL strategy, which hands it to the engine with the caller's context, so the engine's middlewares run. - **Fail closed.** If the engine lacks the member, or no engine is registered, the plugin's answer is "cannot say", and the strategy declines then too. The plugin says this once at `warn`. The cost is the native fast path for every object on such a host: its queries are served by the ObjectQL strategy, and a query that strategy cannot serve is refused. A host that builds `AnalyticsService` itself with `executeRawSql` and without the new config member keeps today's native path for every object. It is told so once, at construction. - **Surface notes, declared.** - The `AnalyticsServiceConfig` member sits outside the "context construction sites" region of `analytics-service.ts` that the claim names. The service cannot learn the engine's answer any other way, and it follows the same pattern as `judgeFilter`. - Six plugin-level suites' engine doubles now carry `hasObjectMiddleware: () => false`, which models the engine they stand in for. Without it they measured the fail-closed tier: 22 tests went from served to refused. The suites are `admission-bridge-resolution`, `effective-datasource-probe`, `field-query-admission-gate`, `field-read-admission-gate`, `raw-sql-object-routing` and `read-scope-bridge-resolution`. ## Per gate, by class The boot is real and org-bound: better-auth sign-ups, the real security plugin, and the real audit, storage and approvals plugins writing their rows, on SQLite. The restricted member is admitted to the gated object at object level and cannot read some of the parents. The admin is the unrestricted control. The door is the analytics dataset door. The strategy was read by counting each strategy's `execute`. The reference is the generic data door's list for the same caller. | gated object (gate) | strategy before → after | restricted member, before | restricted member, after | data door, same member | |:--|:--|:--|:--|:--| | comment threads (`plugin-audit` comment read gate) | native → ObjectQL | groups and count include a thread about a parent it cannot read | equal to the data door | only threads about readable parents | | activity rows (`plugin-audit` activity read gate) | native → ObjectQL | groups include a parent it cannot read; the count is the system total | equal to the data door | only rows about readable parents | | attachments (`service-storage` attachment read gate) | native → ObjectQL | groups include an unreadable parent; the count is the system total | equal to the data door | only rows on readable parents | | approval requests (`plugin-approvals` snapshot redaction) | native → ObjectQL | a group key carries a snapshot field the data plane masks for this member | unchanged: still carried | list: redacted; grouped query: carried (out-of-scope finding below) | - **Admin control.** For comment threads and attachments, the admin's analytics answer already equalled the data door before the change and still does. For activity rows, the admin's analytics answer before also carried rows that the gate excludes for every caller: the rows about a record that no longer exists, or that name none. After the change it equals the data door. - **Control object.** An object no middleware names (`cmt_open`) is served by the native strategy before and after. ## Measured first (H1 to H4) - **H1** is reproduced on current `main` as the card describes, for comment threads and activity rows. The table above has both readings. - **H2, attachments.** The attachment read gate is object-keyed and served nothing on this path before the change. It is covered by construction, and the row above has the reading. - **H2, approvals.** - The snapshot redaction is not global. It has been object-keyed since it landed, `{ object: 'sys_approval_request' }`, so the engine's answer covers its routing by construction. Its registration is unchanged and not in this PR. - It served something on the native path: the masked snapshot value in a group key. - After the decline the engine path serves the query. The redaction runs on `find` and `findOne` only, so an aggregate still carries the value, as the generic data door's grouped query does today. That gap is in the redaction's operation set, and it is a separate finding (below). It is not this card's mechanism. - **H3, multi-organization.** A boot with the organizations plugin under the `isolated` posture and a declared membership policy. A caller outside the admin's organization gets the same answer from the native analytics path as from the data door (none of the admin organization's rows), and the admin gets the same answer from both. The organization wall reaches this path through the security service's read filter (Layer 0 in `getReadFilter`), so the native path applies it. NOT MEASURED: a member of a second organization that holds rows of its own (the outsider in this boot was bound to no organization). - **H4.** The route is the three parts described above. The fail-closed tier and its cost are stated above and pinned below. ## Census (H5) The census was read off the engine's registrations on a showcase boot with the stock plugin set (`serve` auto-registers audit, storage, sharing and approvals, beside security): - **Read gates (4 objects):** `sys_comment` and `sys_activity` (`plugin-audit`; `sys_activity` also carries the field-value redaction), `sys_attachment` (`service-storage`), and `sys_approval_request` (`plugin-approvals`, the snapshot redaction). - **Write-only middlewares (2 objects), moved as a side effect:** `sys_user_position` (the position-catalog refusal, on insert and update) and `sys_permission_set` (the data-door write-through, on insert, update, delete and restore). A middleware does not declare its operation, so these leave the native path too. - **Global registrations** (8 on that boot) are keyed to no object and move nothing. - **Datasets and dashboards that leave the native path: none.** - The showcase datasets read `showcase_task`, `showcase_project`, `showcase_invoice` and `showcase_account`. - The platform system dashboards' datasets read `sys_user`, `sys_organization`, `sys_session`, `sys_package_installation` and `sys_audit_log`. - None of these is in the set above, and none joins one. ## What is newly refused (the narrowing) This is a narrowing, measured. The ObjectQL strategy refuses a dimension reached through a relationship path combined with a measure that cannot be recombined across it (`avg`, `count_distinct`), with `INVALID_FIELD` / `400`. The native strategy served that query. - When such a query reads a gated object, it is now refused. - On a host whose engine cannot say, the same query is refused for every object. - The same query on an ungated object is still served natively. Correctness wins over speed for a gated object, per the ruling. ## Pins (committed red, before the fix) The pins were committed at `5c6d445e3a`. The fix is at `bae287b92e` and the changeset at `e74617add7`. - **`packages/objectql/src/engine-has-object-middleware.test.ts`, the engine accessor.** It covers object-keyed `true`, unnamed `false`, global-only `false`, an empty engine, and read-only: the accessor runs no middleware, with a positive control on a later read. At the pin commit 4 of 4 failed. After the fix 4 of 4 pass. - **`packages/services/service-analytics/src/__tests__/engine-middleware-decline.test.ts`, the decline and the fail-closed tier.** It covers the base object, a declared join, and a relationship-path object in the door's set. It also covers a hook that cannot answer, plus two controls (an ungated object, and a context with no hook), and it runs end to end through `AnalyticsService` and through `AnalyticsServicePlugin`: an engine that names the object, and an engine without the member, which declines every object and says so once. At the pin commit 7 failed and 3 passed of 10 (the 3 controls passed). After the fix 10 of 10 pass. - **`packages/qa/dogfood/test/analytics-engine-middleware-objects.dogfood.test.ts`, the per-object answers.** For comment threads, activity rows and attachments, the restricted member's analytics groups and count equal the data door's for the same member, with the admin as the control. Against the pre-fix build 4 failed and 2 passed of 6 (the two admin controls whose answers already agreed passed). After the fix 6 of 6 pass. ## Ablation (the decline removed, from the committed state at `e74617add7`) The prediction was written down before the run. The mutation was made through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `9e382c78` → `84976adb`), and a trap restored it by absolute path. The mutation replaced the one decline call in `canHandle` with a bare reference to the helper, so the helper stays referenced and the DTS build stays clean. - **Leg A, resolved from source.** The decline pin file failed 7 and passed 3 of 10, the same 7 that were red at the pin commit. The six double-carrying suites stayed green: 101 of 108 passed across the seven files. - **Leg B, resolved from `dist/`.** `service-analytics` was rebuilt. `ablation-dist-preflight --absent` proved the decline call gone from all 6 built files. The dogfood pin failed 4 and passed 2 of 6, exactly the 4 that were red at the pin commit. - **The accessor pin**, which this ablation does not touch, passed 4 of 4. - **Restore.** `git checkout HEAD --` on the absolute path brought the blob back to `9e382c78`, equal to the HEAD blob, and `git diff HEAD` on the path was empty. After a rebuild, the preflight in default mode found the decline call present in 2 built files. The decline pin passed 10 of 10 and the dogfood pin 6 of 6. The tree was clean. ## Verification All of it ran at HEAD `82c4552cbf` (this branch with `main` merged at `bafb8c9498`), as ONE locked script. Each exit code was captured before any pipe. - **Gate families.** `dispatch-gates --commands` derived 90 families from this diff. 89 exited 0. `check:dual-build-cjs-loads` is NOT MEASURED: it refused on its own PREREQUISITE NOT MET (exit 3), because it needs a whole-repo build, which CI runs. `dispatch-gates --ran` reports 90 accounted for: 89 run, 1 NOT MEASURED, 0 unrun. - **Roster families.** The six roster families the derivation marked for these paths all exited 0: `check-changeset-fixed`, spec `check:meta-url-spelling`, spec `check:spec-changes`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. Spec `check:generated` also exited 0, so every generated artifact is current. - **Typecheck, per touched package:** `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/service-analytics` and `@objectstack/dogfood` all exited 0. - **Tests, per touched package, every vitest project:** | package | project | files | tests | |:--|:--|:--|:--| | `@objectstack/objectql` | `local` | 359 | 7,055 passed | | `@objectstack/objectql` | `repo` | 1 | 5 passed | | `@objectstack/spec` | `local` | 594 | 17,399 passed, 1 todo | | `@objectstack/spec` | `repo` | 47 | 832 passed | | `@objectstack/service-analytics` | (one project) | 158 | 3,614 passed, 21 skipped | - **Dogfood, the analytics pins and the gate pins this change routes:** 12 files, 94 tests, all passed. They are the new pin, the eight analytics dogfood files, the comment matrix, the activity gate pin, the attachment count-parity pin and the approval snapshot pin. - **Lint, a declared narrowing.** `eslint --no-inline-config --format json` over the 15 changed TypeScript files linted 15 files, with 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting, so this diff cannot move a verdict on an untouched file. The whole-repo `pnpm lint` runs in CI. ## Acceptance notes - **Out-of-scope finding, reported to the seat and not fixed here:** - The approval snapshot redaction and the activity field-value redaction run on `find` and `findOne` only. - An `aggregate` that groups by the snapshot column of `sys_approval_request` carries a field value the data plane masks for that caller. This was measured on the generic data door's grouped query, and on the analytics door before and after this change. - The activity field-value redaction shares the mechanism. NOT MEASURED. - NOT MEASURED: PostgreSQL. Every reading above is on SQLite. - NOT MEASURED, by this branch: the whole dogfood suite (CI's Dogfood Regression Gate runs it). The analytics, comment, activity, attachment and approval dogfood pins listed under Verification ran. - `README.md` in `service-analytics` shows a hand-built `AnalyticsService` without the new config member. Such a host is told at construction. The README is outside this claim's surface. - `main` moved after this branch's merge. The three newer commits touch `driver-sql`, `driver-turso`, `lint` and `service-storage`, and they share no file with this diff. PR objectstack-ai#21144 (`engine.ts` and `analytics-service.ts`, other regions) had not landed. Whichever lands second merges `main` and re-runs its pins. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…-purity family (objectstack-ai#21394) Fixes objectstack-ai#21347 Clause-②: no ## What `packages/cli/test/json-stdout-purity.e2e.test.ts` discovers its family from the source tree (every command whose comment-masked source calls `bootSchemaStack(` and declares a `json: Flags.boolean(` flag) and reconciles that set against `FAMILY`. `os migrate audit-metadata-bodies` (added by 336e191, PR objectstack-ai#21144) joined the discovered set without joining `FAMILY`, so the nightly `e2e` tier went red on `main` on the reconciliation case. This PR lists it in `FAMILY` with its bare argv (`[]`). The command's default is a read-only dry run, so the drive boots the stack with plugin-audit's objects registered, reads `sys_audit_log` / `sys_activity`, and writes nothing. The three per-member purity assertions now run against it: one JSON document on stdout through a bare `JSON.parse`, no kernel-logger record on stdout, and every boot diagnostic on stderr. `discoverFamily` is untouched, and the failing case keeps its name and body. No command change was needed. Driven under `--json`, its stdout is exactly one document (measured below), so this PR does not touch `packages/cli/src/commands/migrate/audit-metadata-bodies.ts`, which has another change in flight. ## Measurements All readings are at this branch's single commit `105d266829`, unless the table names another tree. | reading | tree | result | |---|---|---| | repro: `OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/json-stdout-purity.e2e.test.ts` | `43e928dd4c` (origin/main, unmodified) | 1 failed, 40 passed. `AssertionError: expected [ 'meta resync', …(13) ] to deeply equal [ 'meta resync', …(12) ]`; the extra member is `migrate audit-metadata-bodies` | | the same command | `105d266829` | 44 of 44 passed (41 plus this member's 3) | | sibling `test/config-miss-stdout-purity.e2e.test.ts`, nightly tier | `105d266829` | 174 of 174 passed | | `pnpm --filter @objectstack/cli typecheck` (src plus the test layer) | `105d266829` | exit 0 | | `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` | `105d266829` | 244 files passed. Two `published-subpath-*.pin` files refused because `packages/cli/dist` was absent, which is a prerequisite refusal and not a measurement. After a cached full build, both re-ran green (29 of 29) | Direct drive of the member (a dry run against the uncompiled fixture and a fresh SQLite file): exit 0. Stdout is one JSON document: `database`, `apply: false`, a `report` with `sys_audit_log` and `sys_activity` each at 0 scanned / 0 rewritten, `failures: 0`, and `duration`. Stderr carries `[StandaloneStack] no compiled artifact`, `Bootstrap complete`, `Graceful shutdown complete` and the runner's own `[stored-metadata-body-migration] would rewrite 0 of 0 …` line. ## Ablation The fix was committed first (HEAD `105d266829`). The mutation ran through `node scripts/ablation-replace.mjs --anchor " 'migrate audit-metadata-bodies': []," --delete`, wrapping the nightly command above: - **Mutation:** it landed on disk. The anchor count went from 1 to 0, and the blob changed from `2b249b4b8c54` to `4389d5638a10`. A grep in the child counted 0 before the suite started. - **Suite:** 1 failed, 40 passed, with the original signature: `expected [ 'meta resync', …(13) ] to deeply equal [ 'meta resync', …(12) ]`. - **Restore:** the blob after restore, `2b249b4b8c54`, equals the HEAD blob. `git diff HEAD` and `git status --porcelain` are both empty. ## Gates I ran `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` with no paths. Its change set is this one file against merge base `43e928dd4`. It derived 50 commands, which ran one after another at `105d266829`, with each exit code recorded before any pipe. - **Exit 0:** 49 commands. - **Exit 3:** `pnpm check:dual-build-cjs-loads` (PREREQUISITE NOT MET, because 9 packages had no `dist/`). After a cached full build at the same HEAD, it re-ran with exit 0: 105 require entry points across 66 packages load. - **`--ran` reconciliation** over the first sweep: 50 derived, 49 run, 1 NOT-MEASURED (the line above), 0 UNRUN. The derivation also printed a stale-tree note. Local origin/main had moved past the base and changed `.github/workflows/release.yml`, `scripts/release-pending-publish.mjs` and `scripts/release-verify-npm.mjs`. The workflow change adds one invocation of a release script inside a release job. No PR gate places it on this path. **Lint** was narrowed to the one changed file. `eslint --no-inline-config --format json` reported 1 file, 0 errors and 0 warnings. That file is the population eslint's own `--print-config` matches (5 rules). The config enables no type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move any untouched file's verdict. The repo-wide `pnpm lint` belongs to CI. ## Changeset None. The diff is test-only, and `@objectstack/cli` publishes only `dist`, `README.md` and `CHANGELOG.md`. ## Acceptance notes - The reconciliation case is pure source analysis, with no boot behind it. It still lives in a nightly-tier file, so the PR that added this member could not see it go red, and `main` found out only from the nightly. The pre-boot sibling (`config-miss-stdout-purity.e2e.test.ts`) has the same shape. Observation only, nothing filed. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21120
Clause-②: no (narrowing)
What this does
Stored metadata bodies (
sys_metadata/sys_metadata_history'smetadatacolumn — stored datasource credential material included) are declared
write-only and are already redacted on the
/metaand datasource doors and, asof #21086, on the generic data door's reads. This PR closes out the remaining
surfaces that can serve, copy or evaluate such a body, so each either goes
through the one shared redaction seam or refuses — and adds an enumeration pin
so a future surface fails the pin instead of joining the family silently.
One seam, no second dialect. The family-wide primitives — the object set,
the body predicate, the column names and the per-type body redactor — now live
once in
@objectstack/spec/kernel, beside the redactor registry they buildon. The data door's wrappers (projection,
dropType, the groupBy and the newfilter/sort refusals) consume that one definition, as do the audit, analytics
and realtime exits — none of which depends on
@objectstack/metadata-protocol.Audit / activity copy (
@objectstack/plugin-audit). The audit writercopies a stored-metadata row into
sys_audit_log.new_value/old_valueandsys_activity.metadataat write time — a second, admin-readable, at-reststore. The copy now projects the body through the shared redactor, so the
credential is withheld there too, and a one-off migration
(
os migrate audit-metadata-bodies, dry-run by default,--apply, idempotent)rewrites the copies already at rest. Fail-closed: a body the redactor cannot
judge is dropped from the recorded copy.
Analytics (
@objectstack/service-analytics). A query naming the storedbody column of these objects as a dimension, measure, filter or sort is
refused (
400 INVALID_FIELD) at the service door, ahead of every strategy —the posture analytics already takes for a member it will not evaluate.
Realtime (
@objectstack/objectql). Adata.record.*event projects itsafter/changesbody through the same redactor, so a subscriber to theseobjects' events receives no stored credential. (Predicate/bulk events carry
only a count, so they were already safe.)
Data door filter / sort (
@objectstack/metadata-protocol, maintainerruling A). A filter or sort on the body column is refused (
400 INVALID_FIELD),the same family and shape as the merged groupBy refusal. This lands on [security] Stored datasource credentials are served unredacted to an admin through a read path outside the two datasource read doors — detail withheld pending maintainer #21086's
seam, now on
main(merged in; no stacked branch).What stays answerable on both tables: every scalar column —
type,name,scope,state, timestamps — is still grouped, filtered, sorted, counted andserved. Only the body column is affected, and only on these two objects.
Enumeration pin
packages/metadata-protocol/src/stored-metadata-body-family.pin.test.tspinsthe family: the object set is the boundary, every declared
apiMethodsverb onthe two objects is a covered read verb (a new verb or a write verb fails the
pin), and each family surface is enumerated with its disposition and owning
per-package pin.
Tests
metadata-type-redaction.test.ts.audit-writers.test.ts; the migration planners and driven runner (dry-run, apply, idempotent) instored-metadata-body-migration.test.ts.stored-metadata-body-refusal.test.ts.engine-realtime-stored-metadata-body.test.ts.Changed lines well under the 5,000 human-merge threshold.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7