Skip to content

fix(plugin-audit)!: an engine read of sys_activity returns only the rows whose parent record the caller can read - #21069

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20833-activity-parent-read-gate
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20833-activity-parent-read-gate

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Part of #20833

Clause-②: no (narrowing) — an engine read of one platform object returns fewer rows to a caller that is not system context. No schema, accept set, export or stored shape moves: src/index.ts, package.json and src/objects/** are unchanged, and none of the new module-level names appears in the built dist/index.d.ts / dist/index.d.mts (positive control: installCommentReadVisibility does).

What this changes

AuditPlugin mounts a read middleware on sys_activity, beside the one sys_comment already has. On find, findOne, count and aggregate it pre-scans the parent pairs (object_name, record_id) the query would touch under system context, asks which of those parent records the caller can read, and ANDs one branch per parent object into the query. A row is returned only when the caller can read the record it is about.

  • One readability answer, shared. The comment gate's per-object, caller-scoped engine read of the parent is extracted from comment-access-hooks.ts into resolveReadableParentIds, and both gates call it. The parent object's own sharing, RLS and object-level CRUD decide. Nothing derives readability a second way.
  • Batched. One parent read per parent object per request, never one per row. A pin counts them.
  • Fails closed, with the same row classes the comment gate uses for threads: a row about a record the caller cannot read, about a record that no longer exists, naming no record or an object the engine does not know, or naming sys_activity itself.
  • Scan window. The pre-scan keeps the comment gate's 2,000-row bound and its fail-closed warning. It also carries the caller's own orderBy, so on a larger table the window it scans is the one the caller pages through, such as a feed sorted by timestamp.
  • System-context and context-less reads are not narrowed.

Per door, by class

A real org-bound boot (bootStack, orgContext: true) with plugin-audit's CRUD mirror writing the rows. The member holds object-level sys_activity read through one explicit permission set. The admin is the seeded admin. "Parent readable" means the same caller's read of the parent record through the data door.

door caller parent readable by the caller before after
list GET /data/sys_activity member no rows returned absent
list member yes returned returned
list member record no longer exists returned absent
list total member (any) every row (43) the rows returned (38)
by id GET /data/sys_activity/:id member no 200 404
by id member yes 200 200
filtered query POST /data/sys_activity/query (where) member no rows returned none
grouped count POST /data/sys_activity/query (groupBy) member no group present group absent
list, by id, filtered query, grouped count admin yes (every existing record) returned returned
list, grouped count admin record no longer exists returned absent
export GET /data/sys_activity/export member, admin (any) 403 403 (the object's API methods, not this gate)

Measured counts in that boot: member list 43 rows before, 38 after. Admin list 43 before, 41 after. The admin's two missing rows are both about one record deleted before the read.

Pins (committed red, before the fix)

  • packages/plugins/plugin-audit/src/activity-read-visibility.integration.test.ts, commit 97fdaa4. It uses a real kernel, the real AuditPlugin, the real CRUD mirror and a real SQLite driver, and it covers find, findOne, count, aggregate, a scoped query, the batching count, the fail-closed classes and an admin. At the pin commit: 8 failed, 4 passed of 12. After the fix: 12 of 12.
  • packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts, commit 2e261af. It covers the public data doors above. Each returned row is checked by opening its parent through the same door as the same member. Against the pre-fix build: 4 failed, 2 passed of 6. After the fix: 6 of 6.
  • The fix commit 2afc6a0 adds activity-read-visibility.test.ts for the fail-closed branches, the scan bound, the order pass-through and the envelope keys. It also adds a mounting assertion to audit-plugin.test.ts.

Ablation: gate off, red, restore (from the committed fix 2afc6a0)

The mutation deletes the one installActivityReadVisibility(...) call in audit-plugin.ts through scripts/ablation-replace.mjs: anchor count 1 → 0, blob 952d943e → 84a26115. A trap restores it by absolute path.

  • Leg A (plugin read path, resolved from source): the integration pin plus audit-plugin.test.ts gave 10 failed, 12 passed of 22. The restore was proven: blob equals HEAD 952d943e and git diff HEAD is empty. Re-run: 22 of 22.
  • Leg B (dogfood door pin, resolved from dist/): after the rebuild, the JS was emitted. The DTS step refused the now-unused import (TS6133), and the suite consumes the JS. ablation-dist-preflight --absent proved the call gone from all 4 built files. The dogfood pin then gave 4 failed, 2 passed of 6. The restore was proven, the rebuild exited 0, and the preflight proved the call present again. Re-run: 6 of 6.

Verification

At HEAD 22cc56d, run as one locked script. Each exit code was captured before any pipe.

  • pnpm --filter @objectstack/plugin-audit test: 28 files, 392 tests passed. pnpm --filter @objectstack/plugin-audit typecheck: exit 0, test layer included (check:test-typecheck OK, 0 errors).
  • Dogfood, at the fix commit's build: activity-parent-read-gate.dogfood.test.ts passed 6 of 6, and comments-permission-matrix.dogfood.test.ts passed 10 of 10. The comment gate is the one whose readability answer is now shared.
  • Gate families derived by dispatch-gates --commands (95): 91 ran green. 4 are NOT MEASURED, each refusing on its own PREREQUISITE NOT MET (exit 3): check:dual-build-cjs-loads and check:type-check-debt need a whole-repo build, check:i18n needs the built CLI, and spec check:skill-examples needs the built client SDK. dispatch-gates --ran: 95 accounted for, 0 unrun.
  • Lint, a declared narrowing: eslint --no-inline-config --format json over the 7 changed TypeScript files reports 7 files linted, with 0 errors and 0 warnings. An ignored file would report a warning, so none of them falls outside the config's population. eslint.config.mjs sets no parserOptions.project and no cross-file import rule, so this diff cannot move a verdict on an untouched file. The whole-repo pnpm lint runs in CI.

Acceptance notes

  • The card stays open, so this PR says Part of. One read door of this object answers outside the engine's middleware chain, so an engine middleware does not reach it. That door was measured on the same boot and reported to the dispatching seat. It is not described here, and closing it is outside this package.
  • Admins. An admin reads every row about a record that exists, exactly as before. Rows about a record that no longer exists are now excluded for every caller, admins included. The comment gate does the same with a deleted record's thread. The deletion itself stays in sys_audit_log, which this gate does not touch. Whether admins should keep those rows is raised with the seat. The security service gives this object no per-object read-bypass answer to key on, measured on /auth/me/permissions for the seeded admin.
  • Surface addition: content/docs/permissions/system-context.mdx, row 45. check-system-context-census requires every isSystem read site to carry an anchor, and the new middleware's early return is one. The declared counts were regenerated by gen:system-context-census.
  • Not measured: whether a caller who can read a parent record, but not every field of it, sees the before and after values an activity row records. That is field-level and is not this record-level gate's question.
  • Not measured: GET /search returned no sys_activity hits for either principal, before or after, in this boot. That reading says nothing about the gate.
  • Not measured: a boot with more than one organization.
  • Not measured, whole-repo build prerequisites (CI runs them): check:dual-build-cjs-loads, check:type-check-debt, check:i18n (built CLI), and pnpm --filter @objectstack/dogfood typecheck. Its 40 errors locally are all TS2307 for unbuilt packages plus one downstream TS7006, and none is in the added file.
  • Main moved after the branch point. The four gate inputs it changed are not on this PR's paths. The gates ran against this branch's copies.

Generated by Claude Code

claude added 5 commits October 1, 2026 03:41
… the plugin read path

A real kernel, the real AuditPlugin, the real CRUD mirror and a real SQLite
driver: a caller holding object-level sys_activity read is expected to get back
only the rows whose parent record it can read, on find, findOne, count and
aggregate, with one parent read per parent object. Red at this commit: eight
of twelve cases fail because no gate narrows the stream yet.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
… the public data doors

An org-bound boot, a member holding object-level sys_activity read through one
explicit permission set, and plugin-audit's CRUD mirror writing the rows. The
list, its total, the by-id read and both query shapes must answer only rows
whose parent record the member can open. Red at this commit: four of six
cases fail because no gate narrows the stream yet.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…ows whose parent record the caller can read

AuditPlugin mounts a read middleware on sys_activity beside the sys_comment
one. On find, findOne, count and aggregate it pre-scans the parent pairs
(object_name, record_id) the query would touch under system context, asks
which parents the caller can read, and ANDs one branch per parent object into
the query. The readability answer is the comment gate's own: its per-object
caller-scoped engine read is extracted into resolveReadableParentIds and both
gates call it, so one derivation serves both. Rows naming no parent, a
missing parent, an unknown object or sys_activity itself fail closed, as
comment threads do. System-context and context-less reads are untouched.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…f erasing them

The options bags handed to find, findOne, count, aggregate, insert, update and
delete were cast to any, which grew the query-options-erasure test surface by
fourteen sites. They are the declared EngineQueryOptions shapes, so they now
type-check as written.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
…ead gate's early return

The new sys_activity read middleware returns early on a system context, as the
sys_comment one does. Row 45 now anchors it beside the comment gate, and the
declared counts are regenerated from the census.

Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/l label Oct 1, 2026
@github-actions github-actions Bot added 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 1 package(s): @objectstack/plugin-audit, touching 27 documentable anchor(s).

41 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 6073bb96b878eb2980724568ee526157b29ab141.

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

What this run could not see
  • 2 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 — 9 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 6073bb96b878eb2980724568ee526157b29ab141 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ea536dd70752bd4094c1d5ee25e4efc2e4d54d08 — the merge of head 22cc56d6c36b90821525ba353da6bcda7e76355b into base 6073bb96b878eb2980724568ee526157b29ab141, 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 ea536dd70752bd4094c1d5ee25e4efc2e4d54d08 && git checkout ea536dd70752bd4094c1d5ee25e4efc2e4d54d08
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6073bb96b878eb2980724568ee526157b29ab141 22cc56d6c36b90821525ba353da6bcda7e76355b && git checkout -B drift-repro 6073bb96b878eb2980724568ee526157b29ab141 && git merge --no-ff 22cc56d6c36b90821525ba353da6bcda7e76355b

node scripts/docs-audit/affected-docs.mjs --json 6073bb96b878eb2980724568ee526157b29ab141

⚠️ 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 6073bb96b878eb2980724568ee526157b29ab141 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 22cc56d6c36b90821525ba353da6bcda7e76355b
Local-runs: none

Inputs: card #20833 (body and its 3 comments: the dispatch claim, the dev report 5924706600, the seat's ACCEPT 5924729189), PR #21069 (body, 9-file list, net diff merge-base d67b9428..head: 9 files, +886/−31, equal to the PR's own totals), and the 33 check-runs on the head. Disclosure discipline held: classes and positions only; the read door outside the engine middleware chain is neither named nor described here.

① Derived judgments

  1. The narrowing itself — right. A data middleware registered { object: 'sys_activity' } narrows find, findOne, count and aggregate for any caller whose context is present and not isSystem: a system-context pre-scan of the parent pairs the caller's where would touch, one caller-scoped readability answer per parent object, then one $or branch per parent object (object_name + record_id $in the stored values) ANDed into ctx.ast.where. On the generic data doors that is the list, its total, the by-id read, the filtered query and the grouped count — the card's criterion for the engine-middleware doors, and the dogfood pin asserts it per returned row against the same door as the same member.
  2. One derivation — right. resolveReadableParentIds is lifted out of computeThreadVisibilityFilter in comment-access-hooks.ts with its loop body intact: the same withoutOperationPrivateKeys envelope (the plugin-audit's callerContext rebuilds a 5-field subset of the execution envelope, dropping onBehalfOf before the sharing gates that are documented to fail closed on it #7141 rule), the same find(parent, { where: { id: { $in } }, fields: ['id'], limit: ids.length, context }), the same catch-to-empty. The comment gate's thread assembly, verbatim thread_id filter and READ_DENY_ALL are unchanged, and both gates now call the one function. The comment gate's pins (comment-access-hooks.test.ts, comment-read-visibility.test.ts, comments-permission-matrix.dogfood.test.ts) are untouched by the diff and ran on the head (Test Core, Dogfood Regression Gate).
  3. Fail-closed classes — right, and the same as the comment gate's. Excluded for every non-system caller: a row whose parent the caller cannot read; a row whose parent no longer exists (the probe finds nothing — admins included, Q2); a row naming no parent, an object outside the machine-name shape, or an object the engine does not know (the probe throws → empty); a row naming sys_activity itself (no re-entry). A failing pre-scan → deny-all. A pre-scan at the 2,000-row bound → rows beyond it excluded, with the warning. OBJECT_NAME_RE equals the spec's ObjectSchema.name regex (packages/spec/src/data/object.zod.ts:1612) and the comment parser's, so no legal object name is wrongly excluded. A platform object is a parent like any other: sys_user / sys_member / sys_permission_set / sys_metadata rows are kept exactly when the caller can read that record — card acceptance 1.
  4. Batching — right. One caller-scoped read per parent OBJECT per request (sequential), plus one system pre-scan; never one per row. The integration pin counts exactly [act_board:find, act_ledger:find] for a list read.
  5. Not narrowed — right, same class as the comment gate. System-context reads, context-less reads, and non-read operations pass through. Residual (not new): a context-less programmatic read on a bare kernel is unnarrowed by design; every transport carries a context.
  6. Public surface — unchanged; right. src/index.ts is an explicit named barrel (no export *) and is not in the diff, so the new module-level exports (resolveReadableParentIds, installActivityReadVisibility, parseActivityParent, ActivityParent) are not package exports. package.json and src/objects/** are not in the diff. No accept set widens; one read's result set narrows.
  7. Wiring — right. Installed inside the kernel:ready handler under its own registerMiddleware seam check, independent of the comment hooks' registerHook guard, with a loud warning when the seam is absent; a mount pin was added to audit-plugin.test.ts.
  8. system-context.mdx census row — right. Row 45 now also anchors activity-read-visibility.ts#installActivityReadVisibility (the ctx.context.isSystem early return in the registered middleware closure, the one new elevation read), and the declared counts move by exactly that site: 107→108 sites, 113→114 property reads, 104→105 behaviour-bearing, 45→46 files, 90→91 symbols; packages stay 19 (plugin-audit was already counted). Folding into row 45 keeps the page's "rows 1–61 / 62–64" partition true; two-file anchor cells have precedent (rows 22, 49, 51, 58, 59). Judged green by Lint & Repo Gates on the head, which runs check-system-context-census.mjs.
  9. Scan window — right direction, residual stated. The pre-scan carries the caller's orderBy but not its offset, so a caller paging past the 2,000th row in its own order loses rows (closed, warned) — the comment gate's bound, now on a table that grows per mutation. A timeline scoped by object_name + record_id never meets it.

Positions, not measured, same class as the comment gate and not this card's: (a) a sys_activity read now issues caller-scoped reads of parent objects, so an object opted into read audit may record probe reads; (b) field-level values inside a kept row (③ below).

② Semver level

  • Level — right. .changeset/20833-activity-parent-read-gate.md grades @objectstack/plugin-audit: minor, the only package whose src/** moves. The act is a BREAKING runtime narrowing with no public-surface change: AGENTS.md Post-Task Checklist 3 ((narrowing) is BREAKING) and the launch-window convention (check-changeset-no-major; the WHICH-LEVEL ruling in pr-automation.yml, decision batch [WIP] Add query enhancements and advanced validation features #35) carry breaking-ness by the BREAKING banner plus the ADR-0087 disposition, not by major, and ship it minor. patch would also have cleared the level axis (a no declaration stands it down) but would understate a declared BREAKING; minor is the convention's spelling.
  • BREAKING text — right. The **BREAKING**: banner states what narrows and for whom. No FROM → TO mapping is owed: nothing an author can write is removed or renamed (barrel, src/objects/**, package.json unchanged), and the body says so.
  • ADR-0087 disposition — right. Exactly one marker, not-required (no-migration-prescription), a category in the gate's closed set, with its justification; the changeset body carries the same Clause-②: no (narrowing) line the ADR-0087 reader takes the arm from.
  • Clause-②: line — right, and the reader accepts the shape. PR body line 3 reads Clause-②: no (narrowing) — an engine read …. readClause2Line scans every body line (no line-1 requirement); CLAUSE2_KEY_LINE matches; the value token reads no; readArmToken reads (narrowing) from the remainder and does not look past the closing parenthesis, so the trailing prose is inert; clause2LineDescribes is false (the key appears once, no opening backtick). Declared, not malformed, not the no (widening) contradiction. Check Changeset on the head — the job that runs check-changeset-no-major and check-adr-0087-registration against the merge base — is success.
  • Clause-②: no (narrowing) matches the diff (no new key, export or accepted value; one read returns fewer rows) and is the line the dispatch order expected.

③ Boundary flags

  • Q1 — a read door outside the engine middleware chain. Seat decision: a separate security family card at the seat's next filing window, direction as the dev recommends (serve such an object only through the engine path, or refuse the bypass for it; no per-object list, no second registration per gate); plugin-audit: sys_activity has no parent-record read gate, so any object-level read opens every activity row in the environment. Add the same read filter sys_comment has, keeping rows whose parent record the caller can read #20833 stays open. Verified here: PR line 1 is Part of #20833, no closing keyword in title or body, Part-of PR must not also close its card is success, so the cloud#2485 grant keyed on this card's merge commit stays withheld. Answered: the card's criterion is met on the engine-middleware doors this package can reach; the open door is outside this package and this PR and is escalated to that card, not a defect of this diff. Not named here.
  • Q2 — admins lose rows about a record that no longer exists. Seat decision A, keep. Answered: right. It is the comment gate's existing rule for a deleted record's thread, so the one derivation stays one; a carve-out would need a per-object bypass member on ISecurityService (outside the file surface, plugin-security ⛔) with no measured pull; the deletion itself stays in sys_audit_log. Card acceptance 2 holds for every existing record, and the residual is stated in both the PR body and the changeset.
  • Q3 — deviations. (1) Part of in place of the closing keyword: verified above, right. (2) No empty-branch probe push: process-only, no effect on the diff. (3) The system-context.mdx row: on the diff, required by the census gate, judged in ① item 8. (4) The revised admin pin case: the integration pin's admin is the caller the parent stand-in answers every record to, which models the measured service (no per-object bypass on /auth/me/permissions); the dogfood pin's admin reads both fixture records through the real door. Answered: accept all four.
  • Out-of-scope finding, the comment gate's same open door: folded into the Q1 card by the seat. Answered: agree, same class, one close-out.
  • Out-of-scope finding, field-level values in a kept row (a caller who can read the parent record but not every field sees the before/after values the row records; the writer masks credential fields only): outside this record-level gate's criterion and not blocking. Escalated: it is a data-exposure class with no carrier yet — the seat should give it a card or a row in the close-out rather than acceptance notes alone.
  • Dev "not measured" items (multi-organization boot; GET /search said nothing; whole-repo build gates check:dual-build-cjs-loads, check:type-check-debt, check:i18n, skill-examples; dogfood typecheck): the head's check-runs own these — see the gate line below. A multi-org boot stays unmeasured and is not this card's criterion.
  • Gate verdicts on the head (the check-runs ARE the verdicts): 35 check-runs on 22cc56d6, every one completed by 04:37Z — 33 success, 2 skipped (Console Pin Gate, the pin did not move; Packed-tarball smoke (opt-in)), 0 failure. The ones this record leans on, each success: Check Changeset (the Clause-② line, the level axis, the ADR-0087 disposition, no-major); Lint & Repo Gates (the system-context census, the whole-repo lint the dev declared as a narrowing, and the check:i18n and check:skill-examples families the dev left NOT MEASURED); Build Core (check:dual-build-cjs-loads); Type Check · debt ledger (check:type-check-debt); Type Check · workspace, · source gates, · consumer gates (the dogfood typecheck the dev could not run); Test Core 1/6–6/6 (the plugin-audit pins and the untouched comment-gate pins); Dogfood Regression Gate 1/3–3/3 (the new door pin and comments-permission-matrix); Temporal Conformance (live PG + MySQL); Build Docs; Part-of PR must not also close its card; The card this PR closes must claim this branch; No other open PR may claim the same issue and … the same single-writer path; Governed Surface Queue Guard; Spec property liveness. So all four of the dev's NOT MEASURED gate families are answered green on the head.

Implemented-by: claude/issue-20833-activity-parent-read-gate
Reviewed-by: session_01XY5uCwTjZj7884yYtyur4H

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 1, 2026 04:40
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 1, 2026 04:40
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 1, 2026
Merged via the queue into main with commit 6f57888 Oct 1, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20833-activity-parent-read-gate branch October 1, 2026 05:25
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ows about records the caller can read (objectstack-ai#21175) (objectstack-ai#21194)

Fixes objectstack-ai#21175
Clause-②: no

A read of the compliance ledger (`sys_audit_log`) now returns only the
rows about records the caller can read. This implements triage's ruling
A on the card (`5932888473`): the ledger takes the activity stream's
parent-record read gate (objectstack-ai#20833, PR objectstack-ai#21069), and that gate reads the
engine's own answer.

**Status.** Draft. The product consequence for admins (see Acceptance
notes) is awaiting the maintainer's decision on objectstack-ai#21175; this PR's
behaviour is unchanged by patch round 1.

## Patch round 1: what changed since the first push

- **Merged `origin/main` at `1ecb871b`** (merge commit `77d3cc05`; no
rebase, no force-push). PR objectstack-ai#21179 (objectstack-ai#21154) is in that merge. Its
`audit-plugin.ts` mount and this PR's mount sat in different hunks of
the same block and merged without a conflict. The census page
(`content/docs/permissions/system-context.mdx`) did conflict: row 45 now
names both the query guard and the ledger read gate, and the declared
counts were regenerated by `gen:system-context-census` (114 sites).
- **Mount order.** Engine middleware runs in registration order. On
`sys_audit_log` the order is now: (1) the query guard from objectstack-ai#21154, (2)
the ledger field redaction from objectstack-ai#21155, (3) this PR's parent-record read
gate. On `sys_activity` it is: (1) the query guard, (2) the activity
read gate, (3) the activity field redaction. Each guarantee holds:
- the guard judges a query before any read gate runs its pre-scan, so a
refused query never pays it;
  - the read gate ANDs its WHERE before the read executes;
- the redaction narrows the rows that come back, which are only the rows
the gate kept.

  The mount comment in `audit-plugin.ts` states this order.
- **Changeset, now in the activity gate's form** (objectstack-ai#20833 / PR objectstack-ai#21069):
`minor`, a **BREAKING** paragraph, and `Clause-②: no (narrowing)`. The
arm is there because this narrows what a read returns and no accept set
widens; the PR line above keeps the claim's `no`. It carries the
ADR-0087 `not-required (no-migration-prescription)` marker
(`check-adr-0087-registration`: 1 declared-breaking changeset, carrying
its disposition) and a Migration paragraph.
- **CI red on the previous head.** The red was `Dogfood Regression Gate
(3/3)`; reproduced locally on that head. The failing case was
`admin-ledger-decision-metadata.dogfood.test.ts`, a pin objectstack-ai#21174 landed on
`main` after this branch's first merge. Its readers could open only
their own user row through the data door (measured: each reader got 404
on the subject user). Under the parent-record rule they are served none
of the subject's ledger rows, so its armed check disarmed. **Old to
new:** the fixture's reader sets now add view-all on the user object, a
row-scope grant only. A new armed control asserts that every reader
opens the subject through the data door (measured 200 for all five). No
assertion changed: its armed check still measures that each reader is
withheld exactly its field class, and all 8 cases pass.
- **Docs.** `content/docs/permissions/record-view-auditing.mdx`,
"Reading the trail": the sentence saying ledger queries go through the
data service "like any other object" now states the read rule. A view
row is served outside system context only to a caller who can read its
record. Views of a record that has since been deleted stay stored and
are served only to system-context reads.

## Measured first, on a real boot (classes only)

The re-measure was private and its readings stay in the dispatch's
scratch. It ran on `main` at `b9087d77` (PR objectstack-ai#21171 in), and again at
this branch's first head. The stack was `bootStack`, org-bound, with the
real `SecurityPlugin`, auth, REST and `AuditPlugin`. The rows were
written by the CRUD mirror and the auth-event sink. The member holds the
ledger read through one explicit permission set; the admin is the seeded
admin. "Parent" means the same caller's read of the row's record through
the data door.

| door | caller | parent, through the data door | before | after |
|:--|:--|:--|:--|:--|
| list `GET /data/sys_audit_log` | member | 404: a private record's
create, update and delete rows | returned | absent |
| list | member | 404: other users' sessions (their `login` rows) |
returned | absent |
| list | member | 200 | returned | returned |
| by id `GET /data/sys_audit_log/:id` | member | 404 | 200 | 404 |
| list, by id | admin | 200: every existing record | returned | returned
|
| list | admin, member | the record no longer exists (`delete` rows, a
deleted record's other rows, `logout` rows) | returned | absent |
| list `total` | member | (any) | 51 | 42, the rows returned |
| list `total` | admin | (any) | 51 | 47, the rows returned |

## What changed

- `parent-record-read-gate.ts` (new): the activity gate's mechanism,
moved out of `activity-read-visibility.ts` and parameterized per gate.
It still asks `resolveReadableParentIds`, the one readability answer the
comment and activity gates share. Nothing derives row scope a second
way.
- `activity-read-visibility.ts`: now a thin declaration over the shared
module. Its exports (`parseActivityParentObject`, which the objectstack-ai#21154 guard
imports, included), scan options, sentinel and log lines are unchanged,
and its unit and integration pins pass unedited.
- `audit-log-read-visibility.ts` (new): `installAuditLogReadVisibility`,
the ledger's declaration, with the row classes below.
- `audit-plugin.ts`: one mount with its order comment, plus the
no-middleware-seam warning now names the ledger read gate.
- `content/docs/permissions/system-context.mdx`, row 45: the census
anchor for the new early return on system context.

## Row classes: the stated answer for rows the gate cannot judge

Measured per writer:

- **About a record** (`object_name` + `record_id`): the CRUD mirror's
create, update and delete rows; record-view `read` rows; plugin-auth's
administrative create and update on a user; and `login` / `logout`,
which name the session. Judged by the record gate.
- **About a record that no longer exists:** no caller can read the
record, so these rows are excluded for every caller that is not system
context, admins included. That covers every `delete` row, every `logout`
row (sign-out deletes the session, measured), and a sign-in row whose
session has since been removed. The rows stay stored.
- **About no record** (no `record_id`, and an action that is not a
record action): the run-level `import`, `config_change` and
`platform_admin_standing_change` rows, and an auth event without a
session id. These are outside the gate's class and are served under the
ledger's own grant, as before. **Why this differs from the activity
gate**, which excludes every row that names no parent:
  - the activity stream has no platform producer of such rows;
- these rows have three producers, and a shipped consumer reads them
through the data door (the `config_changes` view, pinned by the settings
dogfood test as the admin);
- none of them carries a record's field values (the per-writer
measurement in `audit-log-field-redaction.ts`).
- **Excluded, fail closed:** a record action (`create`, `read`,
`update`, `delete`) that names no record, a record under an object the
engine does not know, and a row naming the ledger itself.

## Pins

- `audit-log-read-visibility.integration.test.ts` uses a real kernel,
the real `AuditPlugin`, the real CRUD mirror and a real SQLite driver.
It covers find, findOne, count, aggregate, a scoped query, the batching
count, every row class, and an admin control. 12 tests.
- `audit-log-read-visibility.test.ts` covers the class rule, the
fail-closed branches, the scan bound and its order pass-through, and the
inert seam. 13 tests.
- `packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts`
covers the public doors above on a real boot. Each returned row's record
is opened through the same door as the same reader. It also covers a row
about no record from its real producer (a settings write), a deleted
record's rows, and the admin control. 8 tests.
- **Composition consequences on other cards' pins (adapted, not
weakened):**
- objectstack-ai#21155's unit test now pins the redaction of rows no non-system door
serves on `redactAuditLogRows` over the row at rest. Its field-values
dogfood pin checks the live record's rows.
  - objectstack-ai#21174's admin-ledger pin is described above.

## Ablation: put the forbidden behaviour back, red, restore

Both mutations ran from committed state through
`scripts/ablation-replace.mjs`, and each restore was proven: blob equals
HEAD and `git diff HEAD` is empty.

- **Mount removed** (the one `installAuditLogReadVisibility(...)` call
in `audit-plugin.ts`; anchor 1 → 0).
- Leg A, source-resolved: the integration pin gave 8 failed, 4 passed of
12.
- Leg B, dogfood, resolved from `dist/`: plugin-audit was rebuilt, and
`ablation-dist-preflight --absent` proved the call gone. The dogfood pin
then gave 5 failed, 3 passed of 8.
- Restore: a rebuild, the preflight proved the call present and the tree
clean, and the pin re-ran 8 of 8.
- **The class rule widened** (a record action naming no record treated
as outside the gate): the unit and integration pins gave 6 failed, 19
passed of 25.
- **objectstack-ai#21174's pin without the reader view-all grant** (the previous
head's fixture), on the merged build: its armed check disarmed. All five
readers were served no mirror row about the subject.

## Verification at HEAD `d376985f`

Every exit code was captured before any pipe.

- `pnpm --filter @objectstack/plugin-audit test`: 35 files, 535 tests
passed. `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0,
test layer included. `pnpm --filter @objectstack/dogfood typecheck`:
exit 0. `plugin-approvals` `payload-predicate-guard.test.ts`: 20 passed.
- **Dogfood shard 3/3** (`OS_TEST_SHARD=3/3`, CI's slice): 54 files
passed, 1 skipped; 523 tests passed, 2 skipped.
- Ledger and neighbour pins, 12 files: this PR's dogfood pin; objectstack-ai#21155's
`audit-log-field-values`; objectstack-ai#21081's `activity-field-values`; objectstack-ai#21154's
`activity-text-predicate` and `audit-log-admin-search`;
`activity-parent-read-gate`; `auth-session-audit-trail`;
`settings-config-change-audit`; `admin-identity-audit-trail`; objectstack-ai#21174's
`admin-ledger-decision-metadata` (8 of 8 after the fixture change);
`comments-permission-matrix`; `membership-actor-attribution`. All green:
eleven ran at `b415f4d7`, whose tree differs from this head only in the
admin-ledger pin, and that pin ran at this head.
- `dispatch-gates --commands`: 94 families derived; 93 ran with exit 0
and 1 is NOT MEASURED. `spec check:skill-examples` exited 0 after the
client SDK it reads was built. `check:dual-build-cjs-loads` exited 3,
PREREQUISITE NOT MET: it needs a whole-repo build, and CI runs it.
`dispatch-gates --ran`: 94 accounted for, 0 unrun. `main` moved again
after `1ecb871b`; this round merges `main` once, as dispatched.
- Lint, a declared narrowing: `eslint --no-inline-config --format json`
over the 10 changed TypeScript files reports 10 files linted, 0 errors
and 0 warnings. An ignored file would report a warning.
`eslint.config.mjs` sets no `parserOptions.project` and no typed rule,
so this diff cannot move a verdict on an untouched file. The whole-repo
`pnpm lint` runs in CI.

## Acceptance notes

- **Admins lose the trail of deleted records and of sign-outs** on every
door that is not system context, including Setup's Audit Logs. The rows
stay stored. This is the consequence awaiting the maintainer's decision
on objectstack-ai#21175.
- **Interim mitigation.** `domain:engine` reports (`5936411631` on
objectstack-ai#21175) that this gate shrinks objectstack-ai#21197's reach to admins: the gate stops
serving a member the ledger rows about a key-material record that member
cannot open. The admin half of objectstack-ai#21197 stands, and that card's own
mechanism is separate.
- **Scan bound.** The shared 2,000-row pre-scan bound now applies to the
ledger, which is a larger table than the activity stream. A broad read
past the bound fails closed and logs a warning: rows outside the window,
and the `total` they would add, are omitted. A record timeline scopes by
`object_name` and `record_id` and never reaches the bound.
- Not measured: doors onto the ledger that answer outside the engine's
middleware chain (`GET /search`, the analytics dataset over the ledger,
export), and a boot with more than one organization.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_

---------

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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants