Skip to content

feat(plugin-security,platform-objects)!: a permission-set assignment shows its validity window and refuses an empty one (ADR-0091) - #22364

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-9272-assignment-validity-window
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-9272-assignment-validity-window

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #9272

Clause-②: no (narrowing)

Time-bounded permission-set assignments on ADR-0091's grant-validity window. The maintainer's ruling, verbatim: 「9272 放到 v18开发」 — permission-set assignments only, ADR-0091's window vocabulary, no second time axis, enforce-with-declare. The declaration half was already on main: sys_user_permission_set.valid_from / valid_until, dropped outside the half-open window [valid_from, valid_until) at every resolution by isGrantActive. This PR adds no field. It adds:

  1. An end-to-end proof that the window decides a user's real data access at every evaluation, with the grants cache on as well as off (H1). It holds on main unchanged.
  2. The authoring surface: the user page's "Permission Sets" list shows the window; the window is set by editing the assignment row (H2).
  3. The derived refusal: a write that leaves valid_until at or before valid_from is refused with 400 VALIDATION_FAILED (H3). BREAKING, graded major (Changesets pre mode, v18 line).

H1 — enforcement, measured on main

All four behaviours hold on origin/main 54c3ce10 with no source change: the new dogfood pin's six enforcement cases were green there; its seventh case, the refusal, was red (201).

Behaviour Already pinned on main New end-to-end pin: packages/qa/dogfood/test/permission-set-assignment-window.dogfood.test.ts
before valid_from: nothing core grant-validity.test.ts "inactive before valid_from, active at and after it" (the predicate); resolve-user-grants-cache.test.ts pin 3 "the OTHER direction flips too: a future valid_from becomes active at its boundary" (a permission-set row, cache on, injected clock); resolve-authz-context.test.ts "a not-yet-active sys_user_position row (future valid_from) does not resolve" (position rows only: no uncached permission-set pin existed) "an assignment before its valid_from grants nothing": GET /data/showcase_invoice answers 403
inside the window: grants resolve-authz-context.test.ts "a row inside its [from, until) window resolves; until is exclusive" (positions); the first leg of cache pin 3 (a permission-set row) "an assignment inside [valid_from, valid_until) grants": 200
at or after valid_until: nothing, at the next evaluation resolve-authz-context.test.ts "an expired direct permission-set grant resolves to nothing — including platform_admin derivation" and "drops permission-set grants outside their validity window (ADR-0091)"; cache pin 3 "a grant whose valid_until falls inside the TTL stops resolving AT the boundary, not at TTL expiry" "an assignment at or after its valid_until grants nothing": 403. "with the grants cache on, no cached answer outlives a window bound — with no write at the bound". "an administrator sets valid_until by editing the assignment row, and the next request is refused"
no window: unchanged (control) grant-validity.test.ts "null/absent bounds = unbounded (pre-ADR-0091 rows unchanged)"; the unbounded contributor row in "an expired sys_user_position row does not resolve" "an assignment with no window grants (the control)": 200, plus an armed precondition in beforeAll (a member with no grant reads 403, one with an unbounded grant 200)

The pin boots the showcase with its own default profile (the CLI wiring). A plain member reads showcase_invoice only through a grant: showcase_member_default opens no read on it and showcase_auditor does (examples/app-showcase/access-matrix.json), so the door answers 200 exactly when the auditor assignment resolves. No existing dogfood pin did this: delegation-of-duty.dogfood.test.ts proves a delegated sys_user_position stops resolving at valid_until through resolveAuthzContext, not a permission-set assignment and not through a data door.

The cache. resolveUserAuthzGrants commits its cache entry with nextGrantValidityBoundary(...) over every row it applied the predicate to, inactive rows included (packages/core/src/security/resolve-authz-context.ts:1160), and the cache expires the entry at the earlier of its TTL and that bound (resolve-user-grants-cache.ts:324). Measured end to end on the real boot: with OS_AUTHZ_GRANTS_CACHE_TTL_MS at one hour, one member's assignment ends and another's starts 6 s ahead; a second request before the bound issues no further read of the first member's grants (served from the cache — the pin asserts the read count), and the first request after the bound is refused for the one and served for the other, with no write anywhere at the bound.

Precondition (#10982). On main the organization-admin role projection honours the window: resolve-authz-context.ts:856-860 filters sys_member rows with isGrantActive before the role derivation, pinned by resolve-authz-context.test.ts "#10982 — a lapsed sys_member row confers no role either".

H2 — the authoring surface

  • Show the window: yes. record:related_list.columns takes field names of objectName (RecordRelatedListProps, packages/spec/src/ui/component.zod.ts), so the list now shows valid_from and valid_until beside the set. Pinned by packages/platform-objects/src/pages/sys-user-permission-set-window-columns.test.ts (red on main), which also parses the block through RecordRelatedListProps. No new label string: the columns carry the object's own field labels.
  • Set it at Add: no. The add contract carries picker, linkField and label only, and the affordance writes exactly the two link keys. It cannot collect another field of the link row. Recorded below as a carrier note; nothing in objectui or packages/spec changes here.
  • Set it by editing the assignment row: yes. sys_user_permission_set is managedBy: 'system-data', whose CRUD affordances include edit (resolveCrudAffordances, pinned in spec object.test.ts), and both bounds are writable datetime fields. The dogfood pin drives what the record form submits, an administrator's PATCH /data/sys_user_permission_set/:id with valid_until, and the member's next request is refused. Not driven in a browser.

H3 — the refusal

Shape: a declared validation rule on the object (validity_window_order, cross_field, in objects/sys-user-permission-set.object.ts), not a hook. Measured on main's engine before writing it, on a scratch copy of the object carrying the rule (deleted after): the engine evaluates an object's declared rules on insert (single and batch) and on update (by id, and for each matched row of a multi-row update, which fetches the prior row for a script / cross_field rule), for system and non-system writers alike; the only context skip in the engine is the state-machine one. All 11 refusal pins below went green on that copy.

The rule refuses when both bounds are set and datetime(valid_until) is at or before datetime(valid_from) (equal bounds are an empty half-open window), and only on a write that makes the window: an insert (previous == null) or an update that moves a bound, judged on the stored row overlaid with the patch. An update that moves neither bound is not judged, so a row stored before this rule with an inverted window keeps taking unrelated edits. Stored inverted assignments in the showcase boot: 0 (asserted in the dogfood pin). The delegated-admin gate's own valid_until checks for delegated positions are untouched; the rule sits on sys_user_permission_set only.

Refused as 400 VALIDATION_FAILED with one field error, rule_violation at valid_until: read through resolveThrownHttpError at the engine, and from the response at the data door.

The refusal pins: packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts

Real ObjectQL over a real SQL driver with the real SecurityPlugin started on it.

Case main this PR
insert, inverted, system writer: refused, nothing stored red green
insert, inverted, administrator: refused, nothing stored red green
insert, equal bounds: refused red green
batch insert with one inverted row: refused whole red green
update by id moving valid_until before the stored valid_from: refused, stored row unchanged red green
update by id (system writer) moving valid_from onto the stored valid_until: refused, stored row unchanged red green
multi-row update inverting the matched windows: refused, no row moves red green
legacy inverted row: an unrelated update lands; moving one bound that leaves it inverted is refused; the repair lands red (the refusal step) green
control: a valid window lands green green
control: one bound alone (either) and no window land green green
control: moving a bound within a valid window lands green green

Ablation

Each mutation through scripts/ablation-replace.mjs on the committed object file (blob f64a027f), restore proven each time (blob equals HEAD, git diff HEAD empty, whole tree clean):

  • A1, the rule disabled (false && prefixed to the condition): 8 red — exactly the 8 that are red on main.
  • A2, the previous clause removed (the rule becomes an invariant): 1 red — the legacy row's unrelated edit is refused. The clause is what keeps a stored violation from freezing the row.
  • A3, at-or-before weakened to strictly-before: 2 red — the two equal-bound cases.
  • A1 through dist/, for the dogfood pin (it resolves @objectstack/plugin-security through its built output): rebuilt; ablation-dist-preflight found the mutation's marker in 2 built files; the door case went red (201) and the six enforcement cases stayed green. Restore leg rebuilt: marker absent, the rule's name present in 4 built files, tree clean.

Changesets and grade

  • @objectstack/plugin-security: major, BREAKING banner, ADR-0087 not-required (no-migration-prescription) — a refusal of one stored value combination on a data object; no metadata key, export or authorable surface moves.
  • @objectstack/platform-objects: minor (the two list columns).
  • The grade follows triage's release-state line (after release(v18): enter Changesets pre mode on main (changeset pre enter next) with one major marker, so the first v18 prerelease is 18.0.0-next.0 — the opening ruled B on #22050 #22080, a breaking change grades major; .changeset/pre.json is in pre mode on main). check-changeset-no-major.mjs: "✓ Changesets is in pre-release mode (tag: next) — major bumps are the expected product of an RC window; skipping the no-major guard." Fed a pull-request payload carrying this body's declaration: "✓ LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch." check-adr-0087-registration.mjs: "✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition."
  • The plugin-security translation bundles are regenerated with node scripts/check-i18n-bundles.mjs --write --filter=security for the rule's message; the translated locales carry the source text until translated, as the sibling rules' messages do.

Tests, at 59875daf0a

  • plugin-security: full suite 187 files, 3895 passed, 45 skipped; typecheck exit 0 (test layer compiled, 0 errors).
  • platform-objects: full suite 68 files, 1078 passed; typecheck exit 0.
  • dogfood: the new pin 7 of 7; every dogfood file that writes or reads sys_user_permission_set (34 files) 34 passed, 358 tests passed, 1 skipped (on main with the pins: 33 of 34, the one red being the refusal); typecheck exit 0, the new file in the program (--listFiles).
  • Gates: node scripts/pm/dispatch-gates.mjs --commands derived 69 commands (identical to the claim's reading), all run, all exit 0, reconciled by --ran ("69 derived famil(ies) accounted for — 69 run, 0 NOT-MEASURED"); check:dual-build-cjs-loads first stopped at PREREQUISITE NOT MET (eight packages unbuilt in this worktree) and passed after building them. pnpm check:error-status-conformance by hand: "✓ every derivable runtime status is documented, and every documented status is reachable."
  • ESLint, narrowed: the 12 changed TypeScript files, every one linted under eslint.config.mjs (none ignored), 0 errors, 0 warnings (--no-inline-config --format json). That config enables no type-aware linting, so this diff cannot move a verdict on an untouched file; the repo-wide run is CI's.

Acceptance notes

  • Carrier note (not filed): Add-time collection of the window. record:related_list.add (RecordRelatedListProps) writes the two link keys and collects nothing else, so a window cannot be set in the same step as the grant; it is set by editing the row. The missing capability is an add option that collects named fields of the link row before the insert — a contract key in packages/spec plus its renderer in objectstack-ai/objectui. Carrier: objectstack-ai/objectui.
  • The authz matrix row. authz-conformance.matrix.ts's grant-validity-window row names delegation-of-duty.dogfood.test.ts as its end-to-end proof. The new pin is a second, permission-set-shaped one; it carries no authz-row: claim, so the row and its pairing check are unchanged. Carrier: none.
  • A gate reading's wording. check-changeset-no-major.mjs prints "direction arm: narrowing — a BREAKING change; during the launch window it ships minor" while, in pre mode, it accepts the major this PR takes. The verdict and triage's line agree; only the arm line's sentence is stale in pre mode. Carrier: none.
  • The window-order message is not translated in zh-CN / ja-JP / es-ES yet (source text, as above).

Generated by Claude Code

claude added 7 commits October 8, 2026 21:28
Four pins, written against main before any change:

- dogfood: a showcase member's real data access follows the window on
  their sys_user_permission_set row (no window, before valid_from,
  inside, after valid_until), at the next evaluation with no write at
  the bound and with the grants cache on, and after an administrator's
  edit of the row. The door's refusal of an inverted window is red here.
- plugin-security: the write door refuses an assignment whose window end
  is not after its start (insert, batch, update by id, multi-row update,
  system and non-system writers); red on main.
- platform-objects: the user page's permission-set list shows the window;
  red on main.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…nment's validity window

The "Permission Sets" related list on the sys_user page shows valid_from
and valid_until beside the set. The resolver drops an assignment outside
its half-open window at every evaluation (ADR-0091 D2), so a list showing
only the set presented an expired or not-yet-active assignment as a live
grant.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…ndow that ends at or before it starts

A permission-set assignment is active in the half-open window
[valid_from, valid_until) (ADR-0091 D1/D2), so a window whose end is not
after its start grants nothing: the resolver drops the assignment at every
evaluation. Such a write used to be stored without a word.

The object now declares one cross_field validation rule,
validity_window_order, refused as 400 VALIDATION_FAILED with a
rule_violation at valid_until. The engine evaluates it on every write
path, system writers included: insert (single and batch) and update (by
id, and per matched row of a multi-row update), against the stored row
overlaid with the patch. A `previous` clause limits it to an insert or an
update that moves a bound, so a row stored before the rule with an
inverted window keeps taking unrelated edits.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
…indow_order

Generated by `node scripts/check-i18n-bundles.mjs --write --filter=security`;
the translated locales carry the source text until translated, as the
sibling rules' messages do.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
The /data door answers a validation refusal with `code` and `fields` at
the body's top level; the pin read `error.code`. It also names the field
and code the refusal attaches.

Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l 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 2 package(s): @objectstack/platform-objects, @objectstack/plugin-security, touching 11 documentable anchor(s).

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

  • content/docs/api/error-catalog.mdx (via permission_set_id (literal, a string literal in slots))
  • content/docs/automation/approvals.mdx (via valid_from (literal, a string literal in slots; a string literal in validations), valid_until (literal, a string literal in slots; a string literal in validations))
  • content/docs/data-modeling/objects.mdx (via SysUserPermissionSet (symbol, a top-level const object), sys_user_permission_set (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/data-modeling/seed-data.mdx (via cross_field (literal, a string literal in validations))
  • content/docs/data-modeling/validation.mdx (via cross_field (literal, a string literal in validations))
  • content/docs/deployment/environment-variables.mdx (via sys_user_permission_set (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), valid_until (literal, a string literal in slots; a string literal in validations))
  • content/docs/permissions/authentication.mdx (via granted_by (literal, a string literal in slots))
  • content/docs/permissions/authorization.mdx (via sys_user_permission_set (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), granted_by (literal, a string literal in slots), valid_from (literal, a string literal in slots; a string literal in validations), valid_until (literal, a string literal in slots; a string literal in validations))
  • content/docs/permissions/delegated-administration.mdx (via sys_user_permission_set (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), granted_by (literal, a string literal in slots))
  • content/docs/permissions/permission-sets.mdx (via sys_user_permission_set (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), granted_by (literal, a string literal in slots), permission_set_id (literal, a string literal in slots))
  • content/docs/permissions/positions.mdx (via granted_by (literal, a string literal in slots))
  • content/docs/permissions/system-context.mdx (via sys_user_permission_set (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), permission_set_id (literal, a string literal in slots))
  • content/docs/protocol/objectql/schema.mdx (via cross_field (literal, a string literal in validations))

⛔ 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 (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v13.mdx (via sys_user_permission_set (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), granted_by (literal, a string literal in slots))
  • content/docs/releases/v14.mdx (via sys_user_permission_set (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), valid_from (literal, a string literal in slots; a string literal in validations), valid_until (literal, a string literal in slots; a string literal in validations))
  • content/docs/releases/v16.mdx (via sys_user_permission_set (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v17/17-0.mdx (via cross_field (literal, a string literal in validations), granted_by (literal, a string literal in slots))
  • content/docs/releases/v17/17-1.mdx (via sys_user_permission_set (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v17/17-5.mdx (via cross_field (literal, a string literal in validations))

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 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 38 pages), organization_id (literal, 33 pages)
  • 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 — 18 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 3f80f17167bae561cb2a278668349bd54639f642 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 3f80f17167bae561cb2a278668349bd54639f642 → 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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

权限设置应该可以配置实效性,权限在一个时间段之内生效

2 participants