Skip to content

fix(security): close out the stored-metadata-body family — project or refuse every further read/copy/evaluate exit - #21144

Merged
objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-21120-stored-metadata-body-family
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 12 commits into
mainfrom
claude/issue-21120-stored-metadata-body-family

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21120

Clause-②: no (narrowing)

Security boundary — human floor. This PR stays in DRAFT. It narrows a
credential-disclosure surface; landing is the maintainer's. No AI seat flips
it ready, queues it or arms auto-merge.

What this does

Stored metadata bodies (sys_metadata / sys_metadata_history's metadata
column — stored datasource credential material included) are declared
write-only and are already redacted on the /meta and datasource doors and, as
of #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 build
    on. The data door's wrappers (projection, dropType, the groupBy and the new
    filter/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 writer
    copies a stored-metadata row into sys_audit_log.new_value / old_value and
    sys_activity.metadata at write time — a second, admin-readable, at-rest
    store. 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 stored
    body 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). A data.record.* event projects its
    after / changes body through the same redactor, so a subscriber to these
    objects' events receives no stored credential. (Predicate/bulk events carry
    only a count, so they were already safe.)

  • Data door filter / sort (@objectstack/metadata-protocol, maintainer
    ruling 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 and
served. Only the body column is affected, and only on these two objects.

Enumeration pin

packages/metadata-protocol/src/stored-metadata-body-family.pin.test.ts pins
the family: the object set is the boundary, every declared apiMethods verb on
the 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

Changed lines well under the 5,000 human-merge threshold.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MRdbfpy4sQT8bUjmMhxsN7

claude added 5 commits October 1, 2026 08:38
…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
… 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
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/cli, @objectstack/metadata-protocol, @objectstack/objectql, @objectstack/plugin-audit, @objectstack/service-analytics, @objectstack/spec, touching 52 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/cli/vitest.config.ts, packages/plugins/plugin-audit/src/index.ts, packages/spec/api-surface/kernel.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

56 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json f0cc16e8d55b02269c7db708254e2917ba7e84e2.

⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 4 changed file(s) yielded no anchor (packages/cli/vitest.config.ts, packages/plugins/plugin-audit/src/index.ts, packages/spec/api-surface/kernel.json, …) — pages documenting those are invisible to this run
  • 9 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 146 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f0cc16e8d55b02269c7db708254e2917ba7e84e2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 05a6397133583889cc6ed84e2813896912e0cc7c — the merge of head 5397aa8ba8d301d6e0261f611af1c83f2ed5e6b3 into base f0cc16e8d55b02269c7db708254e2917ba7e84e2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f0cc16e8d55b02269c7db708254e2917ba7e84e2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 4 commits October 1, 2026 09:52
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
…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
…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
claude added 2 commits October 1, 2026 11:30
…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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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 session_01MRdbfpy4sQT8bUjmMhxsN7 on 2026-10-01; the PM review is recorded on #21120. CI on head 5397aa8ba8 is 33 success / 2 skipped / 0 failed, mergeable clean. The PR is now ready with auto-merge armed, and the merge queue re-validates on the merged generation.


Generated by Claude Code

Merged via the queue into main with commit 336e191 Oct 1, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21120-stored-metadata-body-family branch October 1, 2026 14:16
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…-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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants