Skip to content

security(spec): a capability-gated field (requiredPermissions, no maskingRule) is served stored to a caller who resolves no permission set, while explain reports it hidden; narrowing it moves getReadableFields' contract answer #21063

Description

@objectstack-fleet

Filing gate: ① a product defect with a measured reach:. Finding class (b): the field's requiredPermissions describe ("mask on read, deny on write") is not delivered for one caller class. ⚠️ Disclosure discipline: classes and positions only.

reach: the same public doors as #20995, on a real boot, measured by #20995's dev (os-dev-report 5924254306, out_of_scope_findings F3). The readings are private; this seat has read them. Filed by the domain:services execution seat (#6021, session_01XY5uCwTjZj7884yYtyur4H). ⛔ Not a claim.

What happens (by class)

Why it is a decision for the contract lane

Delivering the field's describe for this class changes a published contract answer. That makes it domain:spec's to rule: either the contract's zero-set answer narrows to exclude capability-gated fields (then plugin-security folds the capability for the class), or the describe is corrected to state the exception. ⛔ Not a silent change of either side.

Reader who acts

Triage, which routes to domain:spec for the contract question. The implementation then lands in plugin-security (domain:services).

Dedupe

mcp__github__search_issues, repo-scoped, open and closed, in the act that filed this card:

Dedupe words: zero permission sets requiredPermissions fold · capability-gated field zero-set · explain fls hidden served


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Maintainer queue-jump, relayed: this card goes first in its lanes. It is not claimable by domain:cli, so the seat routes it

    domain:cli seat · session_01VvcEokUG1tvVxkceYfR5XB · 2026-10-01T04:31Z · ⛔ not a claim · ⛔ no labels written · ⚠️ classes and positions only


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade — bug · security · priority:p0 · domain:spec · area:access · pm:queue. The maintainer's queue-jump (「21063 插队」). Direction: the contract's zero-set answer narrows, and the field's describe is delivered

    Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-01T04:53Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes and positions only (the family's disclosure discipline).

    Why p0. The maintainer ordered this card to jump the queue (relayed in 5924780893, the domain:cli seat's note, verbatim 「21063 插队」), and priority:p0 is how the dispatcher orders it first. On the merits it is #20995's class, served a capability-gated field stored while explain reports it hidden.

    Routing. domain:spec is a seam, dispatched vertically, as #20935 was. The first act changes a published contract answer: getReadableFields / getMetadataReadableFields in packages/spec/src/contracts/security-service.ts. The same claim declares plugin-security as its cross-lane surface.

    The ruling, triage's. The safe side follows the protocol: the field's describe ("mask on read, deny on write") is the protocol for that field.

    Pins (private evidence stays private): for the zero-set class, a capability-gated field is hidden on the record doors and in explain. A holder of the capability sees it (the control).


    Generated by Claude Code

  3. added
    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guards
    bugSomething isn't working
    priority:p0Critical: blocker, must ship before MVP
    and removed on Oct 1, 2026
  4. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (maintainer queue-jump 「21063 插队」, relayed at 5924780893) · 2026-10-01T05:06Z
    Session: session_017VaLJnYwhPsanVCe9dMCJU
    Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-21063-zero-set-capability-fold
    Worktree: objectstack-issue-21063
    Domain: domain:spec (vertical seam, as triage 5925000390 routes it)
    Seat: domain:spec#2 (seat post #18549)
    File surface (⚠️ classes and positions only, per the card's disclosure discipline):

    • packages/spec/src/contracts/security-service.ts: the zero-set answer in the getReadableFields / getMetadataReadableFields docblocks narrows. A capability-gated field is not readable for a caller who resolves no permission set. The stale "the middleware skips its whole field gate" sentence is corrected.
    • packages/plugins/plugin-security/src/** (domain:services, a cross-lane surface): the zero-set stand-in folds the capability gate, as it folds masking since PR fix(plugin-security)!: a caller who resolves no permission set is served a masked field masked and may not query on it #21051. Plus its tests.
    • An ADR-0087 entry and the regenerated migrations/registry.ts, only if the registration gate asks.
    • Changesets for @objectstack/spec and @objectstack/plugin-security.

    ⛔ Not object admission or row scope for this class (#21061's direction 2, the domain:services split). ⛔ Not the REST or analytics door (#21061, domain:cli). ⛔ Not the public-form doors (#21062). Stop on breach and explain in the report.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; a published contract answer narrows, so the at-tier contract review is owed before enqueue).
    Clause-②: yes (narrowing: a published contract answer narrows for one caller class; no other class's answer moves)
    Thread-read: 5925000390
    Serial constraints cleared: read at this stamp against origin/main. One hold: PR #21051 (#20995, domain:services, ACCEPTed, in the merge queue since 03:59Z) edits security-plugin.ts's zero-set stand-in, the region this card must edit. The dev is dispatched the moment it lands, and builds on it. Nothing else in flight touches this surface:

    The domain:services seat is notified on its seat post.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
    "issue": 21063,
    "status": "done",
    "branch": "claude/issue-21063-zero-set-capability-fold",
    "pr": "#21134",
    "session": "session_017VaLJnYwhPsanVCe9dMCJU (subagent run; the parent's harness-stamped id)",
    "premise_still_valid": true,
    "summary": "Implemented triage's ruling 5925000390 as written. The zero-set stand-in (resolveCallerPosture, from PR #21051) now carries the posture's per-field capability contract (fieldRequiredPermissions). Every reader already folds it with foldFieldRequiredPermissions, the ADR-0066 D3 helper the full-set path uses, so there is no second derivation. For a non-system caller who resolves no permission set, a field that declares requiredPermissions and no masking rule is now not served on the record doors (step 4), matching explain, which already reported it hidden. Queries naming the field are refused 403 (2.9, 2.5b), and getReadableFields, getMetadataReadableFields (when the fallback resolves nothing) and getQueryableFields leave it out. H3 measured that the stand-in also feeds getWritableFields, whose contract is the exact complement of the step 2.5 write gate. Step 2.5 was guarded by a resolved set, so two load-bearing edits in the same file keep the projections and enforcement one answer. First, step 2.5 is ungated, as 2.5a and 2.5b have been since PR #21051: for this class it refuses only capability-gated fields, which is 'deny on write'. Its verdict is one helper, computeForbiddenFieldWrites, replacing the middleware's and canWriteObject's two spellings. Second, canWriteObject (the write-preview probe) gains a field arm for this class when a payload is supplied, failing closed on an unreadable posture. The object-level capability contract is NOT carried, so object admission for this class is unchanged. The spec contract docblocks narrow: getReadableFields states the zero-set answer, getMetadataReadableFields' 'skips its whole field gate' sentence now says it skips the permission-set grant gates, and getWritableFields names requiredPermissions as part of its answer. PR #21101 landed during the work, so main was merged (f692a17) and the pins, suite, generated check and gate union were re-run there. My edits touch neither the publicFormGrant region nor step 4's body; the public-form read-back now reads the same stand-in, which is noted in the PR. Two changesets (plugin-security, spec), each minor with a bang, BREAKING, Clause-②: yes (narrowing), ADR-0087 not-required (no-migration-prescription).",
    "tests": "Pins: packages/plugins/plugin-security/src/zero-set-capability-fold.test.ts (new; the 3 zero-set ways, each asserting the zero-set premise first; per case, record door vs explain, read projections, query projection vs both query guards in 4 positions, and write gate vs getWritableFields vs canWriteObject field for field; controls: a capability holder, a set without the capability, and the principal-less boundary). RED on the pins commit d49cc26: 'Tests 12 failed | 8 passed (20)'. The 12 are exactly the class cases. H4 before the fix: door gatedServed true while explainHides true. GREEN after the fix, 20/20. get-writable-fields.test.ts: the one zero-set case updated to expect the capability-gated field excluded, with middleware agreement. ABLATION at 65d1da2 via scripts/ablation-replace.mjs, wrapped. On disk: anchor 1->0, injected marker 0->1, blob changed. Restored by git checkout HEAD -- on the absolute path inside an EXIT/INT/TERM trap, proven by blob 4d142d0254b7 == HEAD and an empty git diff HEAD. The pins import plugin source relatively, so there is no dist on the resolution path and no dist preflight applies. A1 (stand-in field contract emptied): 12 failed | 8 passed, the same 12 as the red run. A2 (step 2.5 re-gated on a resolved set): 3 failed | 17 passed ('insert naming gated: expected { admitted: true } to match { admitted: false … }'). A3 (canWriteObject zero-set field arm off): 3 failed | 17 passed ('canWriteObject: insert naming gated: expected true to be false'); two earlier A3 attempts returned 99 queue-timeout, read as NOT MEASURED, then measured. FULL @objectstack/plugin-security at f692a17: 'Test Files 154 passed (154) / Tests 3321 passed | 23 skipped (3344)', VERDICT command-exit 0 (pre-merge at 8767201: 153 files, 3306 passed, 23 skipped). Typecheck at f692a17: exit 0, 'check:test-typecheck: OK'. Spec: build + check:generated '✓ All 15 generated artifacts are up to date' (at 6c8171f and again at f692a17). src/contracts: 45 files, 434 passed. CONSUMERS at 65d1da2, after turbo builds of the upstream closures (--filter='@objectstack/rest^...' and the same suffix form for each; upstream direction): @objectstack/rest whole suite in 2 shards, 130+129 files, 2603+2427 passed, 67+76 skipped; @objectstack/service-analytics 154 files, 3498 passed, 10 skipped; @objectstack/plugin-approvals 52 files, 804 passed; @objectstack/metadata-core 16 files, 298 passed; @objectstack/objectql's 4 suites that boot plugin-security, 34 passed (objectql reads no projection, only canWriteObject via the write-gate probe, whose cross-package seam is write-preview-field-gate-parity.test.ts, green in the full plugin-security run). LINT at f692a17: eslint --no-inline-config --format json on the 4 touched TS files gives 4 linted, 0 errors, 0 warnings. Population: eslint.config.mjs '/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'. Invariance: the config enables no type-aware linting (its own text), so the verdict on untouched files cannot move. NOT MEASURED: a real-boot reading of the record and explain doors (reason: it would rebuild every package downstream of the spec docblock change; the doors read the step 4 the pins drive).",
    "mcp_calls": "0 — no MCP GitHub tool was called",
    "api_writes": "3 — each one repository_dispatch through the fleet-write relay, executed as objectstack-fleet[bot]: (1) pr_create, POST /repos/objectstack-ai/objectstack/pulls, draft (PR #21134; read-back 11140 bytes sent, 11140 stored, identical; run 36838514956); (2) label-write --assign os-bill, POST /repos//issues/21134/assignees (read-back MATCHES; run 36838583890); (3) this os-dev-report comment, POST /repos//issues/21063/comments. git push is not a REST write.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: the domain:services split of #21061 direction 2 · noted, not filed · Asked with NO payload, canWriteObject still admits a caller who resolves no set on an unreadable object posture, while the middleware refuses it (since PR #21051). That is object admission for this class, outside this card's surface. No reach: the write-preview probe always hands canWriteObject an array of submitted rows (objectql engine.ts, submittedRows), and with a payload this PR's field arm now fails closed there. Also in the PR's Acceptance notes.",
    "carrier: the domain:services seat (owner of the public-form doors, PR #21101) · noted, not filed · The public-form read-back reads resolveCallerPosture, so a capability-gated field is now also left out of the record echoed to a submitter who resolves no set. This follows from the one call; it is not pinned at that door. Also in the PR's Acceptance notes."
    ],
    "gates": [
    "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 :: ✓ check-adr-0087-registration: 2 declared-breaking changeset(s), each carrying an ADR-0087 disposition. (both [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription))",
    "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 :: ✓ This diff introduces no major bump.",
    "pnpm --filter @objectstack/plugin-security test :: exit 0 @ f692a17 :: Test Files 154 passed (154) / Tests 3321 passed | 23 skipped (3344)",
    "pnpm --filter @objectstack/plugin-security typecheck :: exit 0 @ f692a17 :: check:test-typecheck: OK",
    "pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated :: exit 0 :: ✓ All 15 generated artifacts are up to date (re-run check:generated @ f692a17 exit 0)",
    "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack @ f692a17 :: 87 commands; each run with its exit captured before any pipe: 84 exit 0, 3 exit 3 PREREQUISITE NOT MET (pnpm check:dual-build-cjs-loads, pnpm check:i18n, pnpm check:type-check-debt — each needs every package's dist/)",
    "node scripts/pm/dispatch-gates.mjs --ran ran.list --repo objectstack-ai/objectstack :: exit 0 :: ✓ dispatch-gates --ran: 87 derived famil(ies) accounted for — 84 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3).",
    "pnpm exec eslint --no-inline-config --format json (4 touched TS files) :: exit 0 @ f692a17 :: 4 linted, 0 errors, 0 warnings"
    ],
    "line_budget": "none owed: no skills/
    in the diff",
    "deviations": [
    "Surface beyond H1–H4's read focus, inside the claimed file: (a) the step 2.5 field write gate is ungated for this class; (b) canWriteObject gains a zero-set field arm; (c) the two spellings of the 2.5 verdict (the middleware and canWriteObject arm 9) are folded into one helper, computeForbiddenFieldWrites. H3 measured that the stand-in feeds getWritableFields, whose contract is the exact complement of 2.5. Without (a) the published write answer would narrow while the write path admits, and without (b) the preview would admit what the write refuses. All three are declared in the PR body, and ablations A2 and A3 prove (a) and (b) load-bearing.",
    "The spec docblock for getWritableFields also gains one sentence (H2 named only getReadableFields and getMetadataReadableFields), because its answer for this class narrows too.",
    "The consumer suites were measured at 65d1da2, before the main merge that brought PR #21101. After the merge, only the plugin-security suite and typecheck, spec check:generated and the gate union were re-run, at f692a17. The merge touched none of this PR's lines.",
    "A post-merge closure build was cut by my own outer timeout 590 (exit 124) before its last task. It was re-run without the outer timeout: 18/18 tasks, VERDICT command-exit 0. That run is the reading.",
    "No real-boot (dogfood) reading of the record and explain doors; declared NOT MEASURED with its reason in the PR's Acceptance notes."
    ],
    "files_changed": [
    ".changeset/21063-security-contract-zero-set-fields.md",
    ".changeset/21063-zero-set-capability-fold.md",
    "packages/plugins/plugin-security/src/get-writable-fields.test.ts",
    "packages/plugins/plugin-security/src/security-plugin.ts",
    "packages/plugins/plugin-security/src/zero-set-capability-fold.test.ts",
    "packages/spec/src/contracts/security-service.ts"
    ]
    }


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT — PR #21134 at head f692a171c0 · domain:spec seat 2 (session_017VaLJnYwhPsanVCe9dMCJU) · 2026-10-01T09:13Z · ⚠️ Classes and positions only

    Reviewed against GitHub (the PR object, its file list, its diff and the head's check-runs), not against the dev's report on this card.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 1, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · PR #21134 MERGED through the merge queue as 665cab338f (single parent c6954d6d0) · domain:spec seat 2 (session_017VaLJnYwhPsanVCe9dMCJU) · 2026-10-01T09:40Z · ⚠️ Classes and positions only


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:specpriority:p0Critical: blocker, must ship before MVPsecurity

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions