Skip to content

fix(core): the authz prelude's grant read projects the columns it decides on - #24941

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24896-prelude-grant-projection
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24896-prelude-grant-projection

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24896

Clause-②: no (a read's projection: every resolved authz answer is unchanged and pinned so; no accept set, served shape or export moves)

What changed

The authorization prelude (resolveUserAuthzGrants in packages/core/src/security/resolve-authz-context.ts) read sys_user_permission_set with no projection, so the engine computed every formula field of the grant table on every request a grant holder made. That read now names exactly the four columns the resolver decides on, through the module-private tryFind, which gains an optional fields argument. No new export, no error code, and no other read in the prelude changes.

The columns, measured before the edit (base 74fcec1edb)

column read by via
permission_set grantSetNameOf anchorReadsDefaultOrganization, mayApply, the ledger names, anchorsPlatformStanding, the 6b set loop
organization_id (helper also accepts organizationId) grantOrganizationOf grantAppliesInTenant, grantAnchorsPlatformStanding, mayApply, applyingGrants
valid_from (also validFrom) isGrantActive, nextGrantValidityBoundary the upsRows filter; the grants-cache boundary over upsRowsAll
valid_until (also validUntil) isGrantActive, nextGrantValidityBoundary same

Nothing in the prelude reads a grant row's id, user_id (it is only the filter), granted_by, reason or display_title. The camelCase spellings are not declared fields of the object, so the engine never returns them under those keys; only the declared names are projected.

What a projection does in objectql

planFormulaProjection (packages/objectql/src/engine.ts) evaluates only the formula fields a non-empty fields list names. A list of stored columns yields an empty plan, so no formula is computed and the hop a formula would read is never issued. (#22300's widening applies only when a formula field is IN the projection.) No system column has to be named: driver-sql answers exactly the named columns, id included only when named, and the resolver reads no id.

The counts, on the rig of auth-prelude-query-count.pin.test.ts (a principal with one organization-owned direct grant)

core display_title reads round trips
main as declared (own columns) 8 4
main PR #24885's one-hop formula (applied in the test fixture only) 9 (sys_user 3) 5
this PR PR #24885's one-hop formula 8 4

Pins

  • Equivalence (packages/core/src/security/resolve-authz-context.grant-projection.test.ts, new). Each fixture is resolved twice on the batch-equivalence recording double: over full stored rows plus a computed title, as the unprojected read answered, and over projected rows (named columns only, no id), as a driver answers a projection. The whole envelope must be identical. Fixtures: an active grant in the active organization, one owned by another organization, an expired one, one not yet valid, one inside its window, a deployment-wide one (organization_id null) with and without an active organization, an admin_full_access grant owned by the Default Organization (acting elsewhere, and with none active), an expired admin_full_access grant, another organization's admin_full_access, and all states at once. The batch-equivalence FIXTURES matrix runs through the same comparison. A further case pins that the grant read names exactly the four columns and that no other prelude read names any.
  • Count (packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts). The rig gains an optional direct grant, and a variant of the grant table whose display_title reads one hop through user_id, with the same expression as PR fix(plugin-security): a permission-set assignment is titled by the person's name, not the raw user id #24885. A new grant-holder case runs under both titles and holds 8 reads in 4 round trips. The case first proves the hop is live on that engine: an unprojected read computes Prelude → prelude_editor from the user's name.

Verification (head 41b479266, after merging origin/main at 71696121a)

  • Ablation: projection reverted (--hold with a dist marker, preflight proved the marker in core's dist/). The count pin went red only under the one-hop title (sys_user 3, expected 2). The equivalence pin's projection case went red. Restore: ablation-replace reports blob == HEAD (6d3b1baf7150) and an empty git diff HEAD. Rebuild and ablation-dist-preflight --absent report the marker absent from all 14 built files and the tree clean.
  • Column sweep: each of the four columns dropped from the projection in turn. Envelope-equivalence fixtures went red every time: permission_set 11 failed, organization_id 11, valid_from 3, valid_until 6. Every leg restored blob == HEAD.
  • Suites (exit 0 each):
    • @objectstack/core test: 108 files / 2623 tests.
    • @objectstack/core test:repo: 6 / 82.
    • @objectstack/plugin-security test: 196 files / 4075 passed, 45 skipped.
    • @objectstack/plugin-auth test: 171 files / 3283 passed, 10 skipped.
  • Typecheck: @objectstack/core and @objectstack/plugin-auth both exit 0. Both test layers compile with no new debt.
  • Build: the full build (turbo run build, docs excluded) exits 0.
  • Gates: dispatch-gates.mjs --commands derived 70 families, all run with exit 0, and --ran reconciles: 70 derived, 70 run, 0 NOT-MEASURED, all exit codes recorded. check:dual-build-cjs-loads first exited 3 (no dist yet). check:lean-entry-closure first exited 1, because it read spec's dist while the full build was rewriting it. Both exit 0 when re-run on the finished build.
  • Pin sweep:
    • The batch-equivalence and request-rounds goldens record object, where, limit and isSystem, never fields, so none moves.
    • No pin anywhere asserts the grant read's options exactly.
    • The one pin that moves is the query-count pin, which gains its grant-holder case.

Cross-lane

packages/plugins/plugin-auth/src/auth-prelude-query-count.pin.test.ts is in domain:services' package. The claim declares this edit as cross-lane, and the seat declares it to that lane before the queue.

Acceptance notes

  • sys_member and sys_user_position, the prelude's other grant reads, are also unprojected. Each declares an own-column display_title formula (sys_member: user and role; sys_user_position: user and position), so today they cost one CEL evaluation per row and no read. They would carry the same read cost if either title gained a hop. Measured and named only; not built (out of scope per the claim).
  • The grants cache default (OS_AUTHZ_GRANTS_CACHE_TTL_MS=0) is untouched.
  • PR fix(plugin-security): a permission-set assignment is titled by the person's name, not the raw user id #24885 can merge main after this lands: its formula then adds no read or round trip to the prelude.

Generated by Claude Code

…ides on

The prelude read sys_user_permission_set with no projection, so the engine
computed every formula field of the grant table in every request a grant
holder made. A record title that reads one hop through user_id would add a
sys_user read and a round trip to each such request.

The read now names permission_set, organization_id, valid_from and
valid_until - every column the resolver reads off a grant row - through the
module-private tryFind. Pinned: the resolved envelope is identical over
projected and full rows for every grant state, and the prelude's query-count
pin gains a grant-holder case that stays at 8 reads in 4 round trips under a
one-hop title.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/core, touching 7 documentable anchor(s).

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

  • content/docs/automation/approvals.mdx (via valid_from (literal, a string literal in GRANT_ROW_COLUMNS), valid_until (literal, a string literal in GRANT_ROW_COLUMNS))
  • content/docs/data-modeling/objects.mdx (via sys_user_permission_set (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/deployment/environment-variables.mdx (via resolveUserAuthzGrants (symbol, a top-level function), sys_user_permission_set (literal, a string literal in resolveUserAuthzGrants), valid_until (literal, a string literal in GRANT_ROW_COLUMNS))
  • content/docs/deployment/validating-metadata.mdx (via permission_set (literal, a string literal in GRANT_ROW_COLUMNS))
  • content/docs/permissions/authorization.mdx (via sys_user_permission_set (literal, a string literal in resolveUserAuthzGrants), valid_from (literal, a string literal in GRANT_ROW_COLUMNS), valid_until (literal, a string literal in GRANT_ROW_COLUMNS))
  • content/docs/permissions/delegated-administration.mdx (via sys_user_permission_set (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/permissions/explain.mdx (via permission_set (literal, a string literal in GRANT_ROW_COLUMNS))
  • content/docs/permissions/permission-sets.mdx (via permission_set (literal, a string literal in GRANT_ROW_COLUMNS), sys_user_permission_set (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/permissions/system-context.mdx (via permission_set (literal, a string literal in GRANT_ROW_COLUMNS), sys_user_permission_set (literal, a string literal in resolveUserAuthzGrants))

⛔ 7 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 resolveUserAuthzGrants))
  • content/docs/releases/v13.mdx (via sys_user_permission_set (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/releases/v14.mdx (via sys_user_permission_set (literal, a string literal in resolveUserAuthzGrants), valid_from (literal, a string literal in GRANT_ROW_COLUMNS), valid_until (literal, a string literal in GRANT_ROW_COLUMNS))
  • content/docs/releases/v16.mdx (via resolveUserAuthzGrants (symbol, a top-level function), sys_user_permission_set (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/releases/v17/17-1.mdx (via resolveUserAuthzGrants (symbol, a top-level function), sys_user_permission_set (literal, a string literal in resolveUserAuthzGrants))
  • content/docs/releases/v17/17-3.mdx (via tryFind (symbol, a top-level function))
  • content/docs/releases/v17/17-5.mdx (via resolveUserAuthzGrants (symbol, a top-level function))

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
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 35 pages)
  • 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 — 27 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 71696121a64d8bd057fac969a72a84295890ed37 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 520ea21c16ddaa77b7841261daea1353082a143b — the merge of head 41b47926643dec979624d5b7a7e6df3d3c5747bf into base 71696121a64d8bd057fac969a72a84295890ed37, 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 520ea21c16ddaa77b7841261daea1353082a143b && git checkout 520ea21c16ddaa77b7841261daea1353082a143b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 71696121a64d8bd057fac969a72a84295890ed37 41b47926643dec979624d5b7a7e6df3d3c5747bf && git checkout -B drift-repro 71696121a64d8bd057fac969a72a84295890ed37 && git merge --no-ff 41b47926643dec979624d5b7a7e6df3d3c5747bf

node scripts/docs-audit/affected-docs.mjs --json 71696121a64d8bd057fac969a72a84295890ed37

⚠️ 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 71696121a64d8bd057fac969a72a84295890ed37 → 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: TIER_DEFAULT
Head-sha: 41b4792664
Local-runs: none

Scope: the net diff against the merge base 71696121a6 (4 files, +305/−4): resolve-authz-context.ts, the new resolve-authz-context.grant-projection.test.ts, plugin-auth's auth-prelude-query-count.pin.test.ts, and the changeset. Read with them, at this head:

  • every consumer of upsRowsAll / upsRows in resolveUserAuthzGrants, with grantSetNameOf, grantOrganizationOf, grantAnchorsPlatformStanding, mayApply, the applyingGrants loop, and grant-validity.ts's isGrantActive and nextGrantValidityBoundary;
  • plugin-security's sys-user-permission-set.object.ts, for its declared columns.

Also the card #24896 with the claim 6295406119 and the dispatch 6295459203, the dev's os-dev-report 6296305180 (read as claims), and the PR body.

① Derived judgments

  • The projection names every column the resolver reads, and only those: right.
    • grantSetNameOf reads permission_set.
    • grantOrganizationOf reads organization_id (or organizationId). Through it, so do grantAppliesInTenant, grantAnchorsPlatformStanding, mayApply and the applyingGrants filter.
    • isGrantActive and nextGrantValidityBoundary read valid_from / valid_until (or their camelCase spellings).
    • Nothing else reads a grant row. upsRowsAll and upsRows appear only at the read, the active filter, the admin_full_access anchor check, the ledger loop, the applyingGrants filter and loop, and the cache boundary. No path reads id, user_id, granted_by, reason or display_title.
    • All four columns are declared on the object (permission_set, organization_id, valid_from, valid_until).
  • The camelCase alternates are not projected: right. The object declares only the snake_case columns, and the engine keys rows by them, so the alternates never arrive on this read either way. Retiring the helpers' ?? alternates is outside this card; it is noted as an observation and not filed.
  • The read path: right, and nothing else moves.
    • tryFind gains an optional trailing fields and passes it through only when given. It is module-private: no export, no public member.
    • The other four prelude reads pass no fields and issue the same query as before.
  • The pins have teeth.
    • Equivalence (25 tests): each of twelve grant-state fixtures, plus the 11 batch-equivalence fixtures, resolves the WHOLE envelope twice. Once over full rows with a computed title, once over rows carrying the four named columns and no id, as driver-sql answers a projection. The two must be equal.
      • The fixtures cover active, expired, not yet valid, in window, the active organization's, another organization's, deployment-wide (organization_id null), and the Default Organization's admin_full_access grant acting elsewhere and with no tenant.
      • The dev's column sweep (each column dropped in turn, 11 / 11 / 3 / 6 red) shows that every column is load-bearing.
    • Count (in plugin-auth): a real engine on SQLite, a direct grant, and the declared title or a one-hop title through user_id. The case first asserts the hop is live on an unprojected read (display_title resolves the person's name). It then asserts the prelude's exact PRELUDE_READS tally and 4 legs.
    • Ablation: with the projection reverted, the one-hop count case goes red (sys_user 3, expected 2) and the projection case of the equivalence pin goes red. Both legs were restored, blob equal to HEAD.
  • The objectql mechanism: as the dev read it. A fields list of stored columns computes no formula and issues no hop (planFormulaProjection). objectql: a formula field in a fields projection widens it to every column and the rows are never trimmed back — get_record sends owner, org and audit columns to an external endpoint #22300's widening applies only to a formula named in the list. The count pin measures it on the real engine.

② Semver level

  • patch for @objectstack/core: right. Every resolved authz answer is unchanged and pinned so; no accept set, served shape, export or error code moves.
  • Clause-②: no (…) is at a line start in the changeset (line 7) and in the PR body.
  • check-governed-merges matched 0 of 4 paths; 309 changed lines.

③ Boundary flags

Implemented-by: claude/issue-24896-prelude-grant-projection
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-20T17:26Z · ACCEPT

Head: 41b4792664. It carries the projection, the equivalence pin, the grant-holder count case, the changeset and a merge of main.

Card #24896 (p2, performance, area:access; filed by domain:services seat 1, it blocks PR #24885): claim 6295406119, dispatch 6295459203. Dev report: 6296305180.

Item Reading
Shape Draft PR → main. The body opens Fixes #24896. 4 files, +305/−4. Not governed: check-governed-merges matched 0 of 4 paths; 309 changed lines.
Scope The authz prelude's sys_user_permission_set read projects permission_set, organization_id, valid_from and valid_until, through the module-private tryFind. No formula on the grant table is computed there, and a grant holder stays at 8 reads in 4 round trips under a one-hop title. The other prelude reads are unchanged.
Seat's own read Every reader of a grant row traced at this head: grantSetNameOf, grantOrganizationOf (with grantAppliesInTenant, grantAnchorsPlatformStanding, mayApply, applyingGrants), isGrantActive and nextGrantValidityBoundary. They read those four columns and no other. All four are declared on the object. git merge-tree against today's origin/main is clean.
Review TIER_DEFAULT, in-seat. Record 6296487539 is PASS on this head.
Declaration One changeset, @objectstack/core patch, Clause-②: no (a read's projection: …) at a line start, as on the claim and the PR body. No accept set, served shape, export or error code moves.
Cross-lane plugin-auth's auth-prelude-query-count.pin.test.ts (test-only, +63/−2), declared to domain:services on #6021 at 6296470031. No objection on the card, the PR or the seat posts at this read.
Gates Dev at 41b479266: 70 derived, 70 run, all exit 0. core 2623 passed, core test:repo 82 passed, plugin-security 4075 passed, plugin-auth 3283 passed. Ablation red on both pins, and a column sweep red on each of the four columns; each leg restored to HEAD.
CI At this read on 41b4792664: ci-failure GREEN, all 41 check-runs completed, none failed, the six Test Core shards included.

Disposition:

Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24896 then closes the card.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 17:27
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 17:27
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 644e4b2 Oct 20, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24896-prelude-grant-projection branch October 20, 2026 18:03
os-justin pushed a commit that referenced this pull request Oct 20, 2026
Brings #24941 (644e4b2): the authz prelude's sys_user_permission_set
grant read projects the columns it decides on, so this branch's one-hop
display_title is no longer computed on that read. Clean merge, no
conflicts, no os-regen deferral.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
os-justin pushed a commit that referenced this pull request Oct 20, 2026
…erson-named one

Semantic merge conflict with #24941's pin, resolved in one line. Its
direct-grant case "as declared" read the declared sys_user_permission_set
title as the raw user id, a precondition asserted before the count. On
this branch the declared title IS the one-hop person title, so both cases
of the pair now read 'Prelude → prelude_editor'. The counts the pin
holds (8 reads in 4 round trips) are unchanged and green.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
Co-authored-by: Claude <noreply@anthropic.com>
os-justin pushed a commit that referenced this pull request Oct 20, 2026
…ected prelude read

Since #24941 the authz prelude's grant read names the columns it decides
on, so it computes no display_title and the prelude stays 8 reads in 4
round trips for a principal holding a direct grant. The changeset said
9 in 5, a figure measured on the pre-#24941 base.

Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK
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/m tests tooling

Projects

None yet

2 participants