Skip to content

fix(plugin-security)!: granted_by is provenance — the delegated-admin gate stamps the writer on every non-system insert, and the column is readonly - #22243

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22201-granted-by-provenance
Oct 8, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22201-granted-by-provenance

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22201

Clause-②: no (narrowing)

granted_by on sys_user_permission_set and sys_user_position records who wrote a grant. This PR enforces that, following the seat's ruling A in the claim:

  • Both columns are now readonly: true.
  • DelegatedAdminGate.assert first runs the authority decision (authorize, the old body). It then stamps the caller's userId into granted_by on every non-system insert it admits into either table, whatever the payload carried. Tenant-level admins, scoped delegates and self-delegations are stamped alike. A refused write throws before the stamp.
  • This one stamp (stampWriter) replaces the three old conditional stamps. Those ran on the delegate and self-delegation paths only, and only when the value was null.
  • System-context writers are unchanged. isSystem short-circuits the security middleware before the gate.

The change does not widen who may grant or what a grant gives. It touches nothing in packages/objectql or packages/spec, and it writes no data.

Measured: stored granted_by per writer case

All cases ran on a real ObjectQL engine over SqlDriver (better-sqlite3), with the real SecurityPlugin middleware, and so the real gate, in front. OTHER is another existing sys_user that the caller names as the granter.

  • Before = origin/main 3b49318 source, read with the rig at 295745e.
  • Readonly only = option B: readonly: true, gate unchanged.
  • After = this branch.
writer case table before readonly only after
tenant-level admin, names OTHER both OTHER OTHER writer
tenant-level admin, names none both null null writer
scoped delegate, names OTHER both OTHER OTHER writer
scoped delegate, names none both writer writer writer
system-context insert, names OTHER both OTHER OTHER OTHER
invitation placement apply (system) sys_user_position issuer issuer issuer
organization-admin reconcile, triggered by a system sys_member write attributed to a human sys_user_permission_set that human that human that human
non-system UPDATE renaming the granter (admin or delegate) both lands: OTHER stripped: unchanged stripped: unchanged

Mechanism finding (premise correction). Readonly alone does not change what an insert stores. On insert, the engine's create-side static readonly strip skips every sys_ object (staticReadonlyInsertSubject). The card's premise was that "an explicit caller value … is stripped by stripReadonlyFields, so the row lands NULL". That holds on update only. On insert, the caller's value lands as sent. The gate's in-place middleware stamp is therefore what lands, and #14088's hook-write record is not involved. The update strip does judge sys_ objects, and that is what closes the update door.

Measured: audit bucket per case (ObjectQL.inspectDanglingReferences)

row readonly removed (ablation B1/B2) after
granted_by names an id with no sys_user row dangling provenance; no [integrity] warning logged
control: user_id names an id with no sys_user row dangling dangling; exactly one [integrity] warning

Both tables are covered.

Family, measured one by one

  • sys_user_position.granted_by: same writer class, same change. Its non-system writes go through the gate, for both tenant-level admins and delegates (including self-delegation). Its system writer, invitation placement, sets the issuer.
  • sys_record_share.granted_by (plugin-sharing): a different class, left unchanged.
    • The object is managedBy: 'engine-owned', so the ADR-0103 engine-owned guard refuses a user-context generic write.
    • Its one writer is the sharing service's grant. That writer inserts under a system context and stamps granted_by from the acting context's userId, which is null for a rule reconcile.
    • GrantShareInput carries no granter, so no caller can name one. "granted_by = writer" already holds there.

Tests

  • granted-by-writer-provenance.test.ts (new, 20 cases). Each runs on the real engine with the real SecurityPlugin.
    • Non-system inserts store the writer: 2 tables × 2 callers × named or absent.
    • System writers keep their value: the plain system insert, invitation placement and the organization-admin reconcile.
    • A non-system update cannot rewrite the granter: 2 tables × 2 callers.
    • The audit files a deleted granter under provenance with no warning, beside the business-lookup control that lands in dangling and warns: per table.
  • delegated-admin-gate.test.ts (+16 cases). These pin the gate's own half:
    • named or absent granter, per principal and table;
    • batch inserts;
    • a refused insert is not stamped;
    • an update is not stamped;
    • a self-delegation that names another granter;
    • sys_member and sys_position_permission_set never gain the key.
  • write-preview-field-gate-parity.test.ts (fixture). The suite's sys_user_position stand-in now declares granted_by, as the shipped object does. The gate now stamps a tenant-level admin's insert, and the stand-in's missing column was refused as an unknown field before the rule that suite pins was reached.
  • The platform-admin promotion. bootstrap-platform-admin.ts is read only here (PR fix(plugin-security): the boot heal converges on duplicated permission-set names, and a refused existence read never inserts #22214 is in flight), so it is not called. Its path, a plain { isSystem: true } insert, is the system-insert pin.

Ablations

Each mutation went to disk through scripts/ablation-replace.mjs, with the anchor hitting exactly once and the blob changing. Each restore was proven by the blob hash equalling the HEAD blob and git diff HEAD being empty. The subject resolves through relative source imports, with @objectstack/objectql aliased to source, so no dist/ was in the path.

ablation mutation red / green restore
A stampWriter keeps a supplied value (granted_by == null guard) 11 red (every "names another user" pin, real-engine and unit) / 92 green blob 8fb5c414ec38 == HEAD
B1 sys_user_permission_set.granted_by readonly: false 3 red (2 update pins, provenance pin: the row lands in dangling) / 17 green blob 2bed0a5ade90 == HEAD
B2 sys_user_position.granted_by readonly: false 3 red (same three, for that table) / 17 green blob 9df5977a9179 == HEAD
C stampWriter call removed 22 red (11 named, 8 absent, 3 legacy stamp pins) / 81 green blob 8fb5c414ec38 == HEAD

The first B1 attempt was a no-op. The replacement was a substring of the anchor, so the tool refused (replace x1 -> x1), and the command never ran. It was re-run with readonly: false, and the table above shows that run.

Validation

All readings below were taken at b4ed57b0ad, after merging origin/main (0e9371f).

  • plugin-security tests. vitest run --maxWorkers=2 (whole package): 177 files, 3766 passed, 45 skipped, exit 0.
  • plugin-security typecheck. pnpm --filter @objectstack/plugin-security typecheck: exit 0. That covers the build program, the scripts program and check:test-typecheck, which reports 0 errors. tsc --listFiles over tsconfig.test.json lists all three touched test files.
  • Build. turbo run build --filter=!@objectstack/docs: 72 of 72 tasks succeeded.
  • Gates. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, with no paths, derives 65 commands. All 65 ran, plus pnpm --filter @objectstack/spec check:generated, and every one exited 0.
    • --ran reconciliation, with an exit code recorded per command: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN.
    • check-adr-0087-registration: green, disposition not-required (no-migration-prescription).
    • check-changeset-no-major: green, "This diff introduces no major bump". The level axis needs a PR payload, so it is left to CI.
    • check:engine-double-contract: green.
    • check:i18n: green, 9 packages in sync.
    • check:dual-build-cjs-loads: green, 106 entry points across 66 packages.
  • ESLint, narrowed to the changed files.
    • The 6 changed .ts files ran under the repo's own eslint.config.mjs with --no-inline-config. The JSON report lists 6 files, with 0 errors and 0 warnings, so none was ignored.
    • That config enables no type-aware linting (no parserOptions.project, no typed rules; see its own comment), so this diff cannot change the verdict on any file it does not touch.
    • The repo-wide pnpm lint is left to CI.

Acceptance notes

  • The supplied value is replaced, not refused. A non-system insert that names a granter gets the writer, which shows in the returned row. On insert there is no droppedFields entry or strip warning for it, because the create-side strip does not run on sys_ objects.
  • One description is now incomplete. sys_user_position.granted_by still says "(stamped by the delegated-admin gate for delegate writes)". That is still true, but it no longer covers every case. Rewording it would re-key the translated bundles in four locales, so it is left alone. The docs pages under content/docs/permissions/ describe the delegate stamp, which also still holds.
  • No stored row is rewritten. The production row's sentinel is out of scope (triage ruling); clearing it is cloud's maintainer's write.
  • Dogfood is declared to CI and was not run locally. The dogfood suites that read granted_by expect the writer or a truthy value (delegation-of-duty, showcase-permission-zoo, membership-actor-attribution), which this change keeps.

Generated by Claude Code

claude added 5 commits October 8, 2026 07:26
…legated-admin gate stamps the writer on every non-system insert

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…and as audit provenance; changeset

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…and-in the gate now stamps for a tenant-level admin

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Oct 8, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 10 documentable anchor(s).

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

  • content/docs/automation/approvals.mdx (via sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/data-modeling/objects.mdx (via SysUserPermissionSet (symbol, a top-level const object), sys_user_permission_set (literal, a string literal in GRANTED_BY_OBJECTS), sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/deployment/environment-variables.mdx (via sys_user_permission_set (literal, a string literal in GRANTED_BY_OBJECTS), sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/permissions/administrator-guide.mdx (via sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/permissions/authentication.mdx (via sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/permissions/authorization.mdx (via sys_user_permission_set (literal, a string literal in GRANTED_BY_OBJECTS), sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/permissions/delegated-administration.mdx (via DelegatedAdminGate (symbol, a top-level class), sys_user_permission_set (literal, a string literal in GRANTED_BY_OBJECTS), sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/permissions/permission-sets.mdx (via sys_user_permission_set (literal, a string literal in GRANTED_BY_OBJECTS), sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/permissions/positions.mdx (via sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/permissions/system-context.mdx (via sys_user_permission_set (literal, a string literal in GRANTED_BY_OBJECTS), sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))

⛔ 10 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx (via sys_user_permission_set (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/releases/v13.mdx (via DelegatedAdminGate (symbol, a top-level class), SysUserPosition (symbol, a top-level const object), sys_user_permission_set (literal, a string literal in GRANTED_BY_OBJECTS), sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/releases/v14.mdx (via sys_user_permission_set (literal, a string literal in GRANTED_BY_OBJECTS), sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/releases/v15.mdx (via sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/releases/v16.mdx (via sys_user_permission_set (literal, a string literal in GRANTED_BY_OBJECTS), sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/releases/v17/17-0.mdx (via sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/releases/v17/17-1.mdx (via sys_user_permission_set (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/releases/v17/17-4.mdx (via sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/releases/v17/17-5.mdx (via sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))
  • content/docs/releases/v17/17-6.mdx (via sys_user_position (literal, a string literal in GRANTED_BY_OBJECTS))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 — 16 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 3ae59661dc4d5029b1ce02e9cdca5cc6d5f74f83 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 3ae59661dc4d5029b1ce02e9cdca5cc6d5f74f83

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

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

Projects

None yet

2 participants