Skip to content

fix(plugin-security): stamp the access depth on the operation's own envelope - #33303

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/area-A3-33051
Nov 14, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/area-A3-33051

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

Fixes #33051

Area-lease pilot #33059, area A3.

What changes

  • packages/plugins/plugin-security/src/security-plugin-authz-middleware.ts: step 2.6 used to write the per-object access depth (__readScope, __writeScope, the delegator halves) and the external grant stamp straight onto the caller's execution context. It now collects them per operation. At next() it hands them to the rest of that operation's chain on a copy of the envelope. The caller's context is never written. The copy is made at next(), not at 2.6, so the request-wide memos that step 3 stages on the caller's context (membership sets, queues) are already on it.
  • Tests: security-plugin.test.ts and authz-matrix-gate.test.ts now read the stamps from opCtx.context, and assert that the caller's envelope stays unstamped. The external-stamp fixture's update now reaches next(), because a refused write hands nothing on.
  • New pin: packages/qa/dogfood/test/record-page-related-slices-concurrent-read-scope.dogfood.test.ts.
  • packages/core/src/security/operation-private-keys.ts: doc comment only. It said the depth was "stamped in place", which is no longer true.
  • Changeset: @objectstack/plugin-security patch.

Why

The record page route (packages/rest/src/record-page-routes.ts) reads every related[] slice concurrently, and all slices share one caller context. Each slice's findData passes the authz middleware, which stamped that object's depth on the shared context, and then reaches plugin-sharing's middleware, which reads the depth after several awaits. A sibling slice on another object overwrote the depth in between.

On the showcase, showcase_manager holds showcase_contact at readScope: 'org' and showcase_field_zoo at the owner default. The contact find was therefore filtered at own and returned 1 row. Its count ran later at org and reported 26. The route itself holds no shared per-slice state; the defect is in the producer, so the fix is there too. The same fix covers the page's other concurrent reads (streams, timeline counts) and any other door that runs reads in parallel on one context.

Manager, Northwind, related = contacts + field zoo (top 3 each) origin/main e540baf this branch
contacts first contacts: 1 record, total 26 3 records, total 26
field zoo first contacts: 3 3
contacts only (control) 3 3
admin, either order (control) 3 3

Verification

Pin, before and after the fix (dogfood, showcase as shipped)

origin/main e540baf:

× the manager fills every slice whatever its position, and agrees with the slice read alone
AssertionError: showcase_contact: 1 rows beside total 26: expected 1 to be 3
Tests  1 failed | 2 passed (3)

Branch: Tests 3 passed (3). The two CONTROL cases (each slice alone, and the admin in both orders) are green on both trees.

Ablation at a41e121 (fix committed first, one line reverted, then restored)

The mutation is const stamps: Record<string, unknown> = {}; changed to = sc;, which puts the stamps back on the shared context. ablation-replace reported anchor 1 to 0, blob 440ec9c3 to e1a0635d. ablation-dist-preflight reported dist/: marker present in 2 built files.

  • dogfood pin: exit 1, showcase_contact: 1 rows beside total 26: expected 1 to be 3
  • unit pin: exit 1, expected { userId: 'user_agent', …(7) } to not have property "__readScope"

Restore: git checkout HEAD -- … left git diff HEAD empty, the blob hash matched HEAD (440ec9c3), the plugin was rebuilt, and preflight --absent reported the tree clean. The dogfood pin then exited 0.

Package suites and gates
  • @objectstack/plugin-security vitest: Test Files 267 passed, Tests 6607 passed, 45 skipped.
  • @objectstack/plugin-sharing vitest: Test Files 74 passed, Tests 1511 passed.
  • typecheck passed for plugin-security (including check:test-typecheck) and for dogfood. eslint --no-inline-config on the touched files: exit 0.
  • check-adr-0087-registration --base origin/main: no declared-breaking changeset. check:nul-bytes: OK.
  • Derived gate families (75): 72 ran with exit 0. check-plugin-teardown-shape --self-test and check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: shallow clone, and packages outside the build closure have no dist), so they are NOT MEASURED. check:type-check-debt is NOT MEASURED (whole-repo tsc hit the local time limit). CI runs all three.

Clause-②: no
Metadata-protocol: none

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (not touched)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_016WV4d1D3AzXvEhBbz79Pmi

…nvelope

Step 2.6 of the authz middleware stamped the per-object access depth
(`__readScope` and its siblings, plus the external rung's grant stamp)
onto the caller's execution context in place. One caller context serves
every operation a request runs, concurrent ones included, so a sibling
read on another object overwrote the depth before plugin-sharing read
it: the record page's related lists filtered a slice at its sibling's
depth (a manager's contact slice answered 1 row beside total 26).

The stamps are now collected per operation and handed to the rest of
the chain on a copy of the envelope built at `next()`, after the steps
above have staged the request-wide memos on the caller's envelope.

Claude-Session: https://claude.ai/code/session_016WV4d1D3AzXvEhBbz79Pmi
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the domain:server Domain lane — seat card indexed by label:pm:seat label Nov 14, 2026
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 14, 2026
@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/core, @objectstack/plugin-security, touching 1 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/system-context.mdx (via registerAuthzMiddleware (symbol, a top-level function))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/core/src/security/operation-private-keys.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 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 — 47 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 24f2f4014d0c9bbd43ec6928942fa44b21c1dff6 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 24f2f4014d0c9bbd43ec6928942fa44b21c1dff6

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

…rivate-object slice

A page for a plain member opened by the showcase manager, related =
[showcase_private_note, showcase_contact] by owner_id, in both orders:
each slice's ids and total equal the same slice read alone.

Claude-Session: https://claude.ai/code/session_016WV4d1D3AzXvEhBbz79Pmi
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract-review-claim: session_016WV4czuuneAppDnJbrNejw · head dc07073 · 2026-11-14T16:34Z

review#2 (server area pilot, A3).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Review: PASS

Head: dc070731cbfedabd32e6dab710aa82686491b2c5 · reviewer review#2 (session_016WV4czuuneAppDnJbrNejw) · area A3 · Fixes #33051

Clause-②: no is right: no authorable key, value or type moves. Each operation now reads at the depth resolved for its own object.

Checked:

  • Step 2.6 now collects __readScope / __writeScope, the delegator halves and the external grant stamp (externalGrantPatch, the existing pure twin of stampExternalGrant: same key, same object-or-undefined value) into operationStamps instead of assigning them onto opCtx.context. The read/write verb lists and the values are unchanged.
  • The copy { ...opCtx.context, ...operationStamps } is made right before next(), after step 3, so request-wide memos staged on the caller's envelope above (__rlsMembershipStaged, membership keys, queues) are carried into it. The caller's envelope is never written.
  • Readers: every consumer of these keys reads the envelope it is handed. buildSharingMiddleware reads ctx.context at entry and passes it to buildReadFilter / buildWriteFilter (incl. the delegator re-runs built from exec.__delegator*Scope); sharing-service.ts reads __readScope / __writeScope from the context argument; externalGrantSpeaks reads the context argument. security-plugin-sharing-verdicts.ts and security-plugin-explain-access.ts build their own envelopes with explicit scopes. Nothing in the authz middleware between step 2.6 and next(), or after it, reads a stamp back from the caller's envelope; the post-next() seam check reads writeImageCheckSeam, not the context.
  • Writes: update / delete / transfer / restore / purge take the write depth on the same copy, so buildWriteFilter and canEdit downstream read it (the authz-matrix-gate and D10 write-leg tests read it from opCtx.context).
  • Nested operations: a nested operation started downstream with the copy goes through the middleware again and gets its own copy, so the parent's depth is not overwritten under it either. An operation with no permission sets gets no copy and no stamp; buildReadFilter then resolves the owner set with no depth, which is the narrowest reading.
  • Tests: the unit tests read the stamps from opCtx.context and assert the caller's envelope has none (not.toHaveProperty('__readScope'), external grant unchanged on the caller). The dogfood pin drives the record page with two related slices in both orders and asserts each slice equals the same slice read alone, for an org-depth object and for a private object beside it.
  • The operation-private-keys.ts change is comment-only.

Before landing: checks on this head were still running when I read it (11 success, 10 skipped, 15 in progress, none failed). This PASS holds for this head once they are all green. security-plugin-authz-middleware.ts is also touched by #33290 (different hunks): whichever lands second needs a clean merge of main; a conflict-free merge keeps this PASS.

No findings.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-elon-musk · session_016WV4d1D3AzXvEhBbz79Pmi

ACCEPT · area A3

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 17:05
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 14, 2026
Merged via the queue into main with commit 86c18df Nov 14, 2026
68 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/area-A3-33051 branch November 14, 2026 18:06
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 domain:server Domain lane — seat card indexed by label:pm:seat size/m tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Record page related lists under-fill for a non-admin when the slice is not the last one in the page request's related[]

2 participants