Skip to content

fix(showcase): grant the seven navigable objects and the three master-detail children - #16069

Draft
os-warren wants to merge 5 commits into
mainfrom
claude/issue-14453-showcase-permission-gaps
Draft

fix(showcase): grant the seven navigable objects and the three master-detail children#16069
os-warren wants to merge 5 commits into
mainfrom
claude/issue-14453-showcase-permission-gaps

Conversation

@os-warren

@os-warren os-warren commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Fixes #14453

Follow-up — the dogfood CI regression this PR caused, and the authorised file-surface expansion

origin/main (dacb73f4f) is merged into the branch, and the Dogfood Regression Gate (3/3) failure that stood on 00c12c3b6 is repaired at 2f5268f29.

Cause — the grants worked, and that is what broke the fixture.
packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts is DERIVED from
examples/app-showcase/access-matrix.json: it sweeps every showcase_* row of that
snapshot, and its own header states that a newly granted object arriving in the sweep is
the design, with only the fixture maps expected to grow. The fourteen rows added above
pulled nine objects into the sweep for the first time. While those objects were ungranted
the sweep could never reach the payload-building step for them; once granted it does, and
the file's PAYLOAD table had no builder for any of the nine:

TypeError: PAYLOAD[object] is not a function
 ❯ test/showcase-crud-persona-matrix.dogfood.test.ts:253:80
Test Files  1 failed | 42 passed | 1 skipped (44)

Scope — declared, not slipped in. The dispatch fence for this card was
examples/app-showcase/. Editing packages/qa/dogfood/ is outside it, and is authorised:
the maintainer directed that this open PR be followed through to merge. Naming it here
rather than letting it pass as routine. What does NOT change is the changeset disposition:
@objectstack/example-showcase and @objectstack/dogfood are both "private": true, so
no published package's manifest or output moves and the skip-changeset label above still
applies.

Why the fixture grew rather than the sweep shrinking. Skipping the objects that have no
builder, or filtering them out of OBJECTS, would silently restore the coverage the sweep
had while those objects were ungranted — the exact silence this card exists to remove. So
the OBJECTS (20) and PAYLOAD (11) key sets were enumerated and diffed key by key rather
than sampled, and all nine of the difference were filled in. Every payload is the minimal
valid one, with each required field named explicitly, matching the table's existing
convention that a showcase change adding a required field fails the admin control by name
instead of degrading a persona's cell into a silent 400:

object payload required fields it satisfies
showcase_business_unit name name (required text)
showcase_cascade name name; country/province/tier deliberately unset — the B3 fixture's narrowing is on one OPTION and the rule validator judges SUBMITTED values, so an unsubmitted select cannot turn a CRUD verdict into an invalid_option 400
showcase_category name name
showcase_expense_line expense_report, merchant, amount, status all four are required on the object; expense_report is its required master_detail parent
showcase_expense_report name, status: 'draft' name, status; draft is load-bearing, see below
showcase_field_zoo name, f_master_detail name and the required master_detail to showcase_project; every other zoo field is optional by design
showcase_preference name name
showcase_project_membership team, project, engagement both master_detail parents are required
showcase_team name name

Two payload choices are load-bearing rather than filler, and are commented as such in the
file:

  • showcase_expense_report is created draft. Both expense approval flows
    (showcase_expense_signoff, showcase_committee_quorum) start on
    record-after-update gated on status == "submitted" && previous.status != "submitted",
    and approve with lockRecord: true. A row created draft and only ever PATCHed on its
    name marker cannot launch either, so no persona's EDIT/DELETE cell can come back as a
    record lock wearing a permission verdict's clothes.
  • The two new lookup parents stay SEEDED rows for every persona, unlike invoiceId,
    which the file has to resolve to the caller's own. The difference is record-level:
    showcase_invoice carries an owner-scoped RLS rule, whereas showcase_expense_report
    and showcase_team are public_read_write, so a persona reaches the seeded parent
    exactly as well as one it made itself. (showcase_expense_line is
    controlled_by_parent, so its record scope is the report's — also open.)
    beforeAll seeds expenseReportId / teamId through the same firstId helper and the
    same "the showcase seed provides the lookup parents every payload needs" assertion.

The EDIT probe had to generalise, and the MARKER map deliberately did not.
showcase_project_membership is a pure junction — two required master-detail parents, a
select and a percent — with no free-text column to stamp a mark into. EDIT_FIELD was a
straight spread of MARKER writing ${mark}-edited; it is now EDIT_PROBE, a per-object
field/value pair that still derives from MARKER for the eighteen text-marker objects and
lets the junction name its own: engagement moves from the owner it is created with to
reviewer. An unrestricted field, a value the option set accepts, and one that always
differs from the created row, so both the "persisted" and the "changed nothing" branches
stay meaningful.

MARKER therefore has no junction entry, and the one place that reads it — the
denied-CREATE absence probe — now asserts the entry exists before querying with it. Today
no matrix row denies create on that object so the probe never runs for it; when one
arrives the sweep fails RED naming the object instead of querying where: { undefined: … }
and passing vacuously.

The verdict census moved, and its arithmetic is recorded row by row. The file's own
comment requires the arithmetic of each move to live beside the numbers. ROWS 27 → 41,
allow/deny 54/54 → 87/77 (+33 allow, +23 deny), itemised per new row in the source. The
baseline-flip count is unchanged at 9, which is itself checkable: none of the nine
objects carried ANY matrix row before this PR, so no pre-existing cell could change side
and no new row's own grant is weaker than the baseline's on a verb the baseline allows.

Verification of this follow-up — all at head 2f5268f29, exit codes captured bare

Every command was redirected to a file and its status read on the next statement; nothing
was read through a pipe.

command exit verdict quoted from the tool
pnpm --filter @objectstack/dogfood test --shard=1/3 0 Test Files 45 passed (45) / Tests 345 passed (345)
pnpm --filter @objectstack/dogfood test --shard=2/3 0 Test Files 44 passed (44) / Tests 288 passed | 1 skipped (289)
pnpm --filter @objectstack/dogfood test --shard=3/3 0 Test Files 43 passed | 1 skipped (44) / Tests 404 passed | 2 skipped (406) — the reported failure was 1 failed | 42 passed | 1 skipped (44)
pnpm --filter @objectstack/dogfood exec vitest run --reporter=verbose test/showcase-crud-persona-matrix.dogfood.test.ts 0 Test Files 1 passed (1) / Tests 46 passed (46), naming all fourteen new rows individually
pnpm --filter @objectstack/dogfood typecheck 0 echoed tsc --noEmit — a real run, not a zero-package match
pnpm --filter @objectstack/example-showcase build 0 ⚠ 23 author-time warning(s) — see above; nav-object-ungranted 0, security-master-detail-ungranted 0
pnpm --filter @objectstack/example-showcase typecheck 0 echoed tsc --noEmit
pnpm --filter @objectstack/example-showcase test 0 Test Files 28 passed (28) / Tests 379 passed (379)
pnpm lint (repo-wide, eslint . --no-inline-config) 0 clean; the whole population, not a narrowed subset
pnpm check:type-check-debt 3 → 0 first reading OOMed under a 4 GB heap and the gate itself said ⛔ This is NOT a pass and NOT a finding: nothing was measured; re-run with a larger heap: check-type-check-coverage --re-measure: OK — 12 ledger entr(ies) re-measured in 100.3s, 140 raw tsc error(s) total, none above its recorded number

All three dogfood shards were run, not only the failing one, because a new PAYLOAD entry
can move a sibling shard.

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack was re-derived on the
final head and now reconciles at 58 families (up from the 44 recorded below: the change
set gained packages/qa/dogfood/**, and the merge of main moved the derivation itself).
The union of all 58 ran at 2f5268f29 on a clean tree, harvested from --commands rather
than from the prose so no section could be dropped: 58/58 exit 0.

The dogfood typecheck's coverage was checked rather than assumed: tsc --noEmit --listFiles
puts test/showcase-crud-persona-matrix.dogfood.test.ts in the program, so its green is
about the edited file and not about a config that excludes tests.

NOT MEASURED in this follow-up. No app was booted and no HTTP request issued outside
the dogfood harness itself; the other example apps were not built; CI's own conclusions are
the authoritative reading and are not predicted here.


Reproduction, before anything was edited

pnpm --filter @objectstack/example-showcase build on origin/main at 53cbad9f7,
exit 0. The build's own verdict line:

  ⚠ 33 author-time warning(s) — see above

Histogram of those 33 by the rule id each finding prints (sums to 33, i.e. the
histogram is the whole population, not a sample):

count rule
10 approval-approvers-may-resolve-empty
7 nav-object-ungranted
5 react-prop-deprecated
3 security-master-detail-ungranted
2 security-private-no-readscope
2 liveness-planned-property
1 view/row-color-without-colors
1 title-unresolvable
1 flow-loop-body-uncontained
1 flow-draft-status-ambiguous

The card's "seven" is exact. Its master-detail claim is low by two. The card
names showcase_field_zoo as the detail object with no CRUD grant; the build
names three.

The seven, verbatim from the build (message body elided after the first clause,
which is identical in all seven):

  ⚠ app "showcase_app" · nav "nav_settings": navigation exposes object "showcase_preference", but no permission set this stack declares grants read on it — the entry renders, and opening it fails permission-denied for every principal except one holding the platform's built-in wildcard admin set. It works when you browse as an administrator and breaks for the users the app ships permission sets for.
    rule: nav-object-ungranted  at apps[0].navigation[2].children[3].objectName
object nav entry path
showcase_preference nav_settings apps[0].navigation[2].children[3].objectName
showcase_expense_report nav_expense_reports apps[0].navigation[3].children[5].objectName
showcase_team nav_teams apps[0].navigation[3].children[7].objectName
showcase_category nav_categories apps[0].navigation[3].children[8].objectName
showcase_business_unit nav_business_units apps[0].navigation[3].children[9].objectName
showcase_field_zoo nav_field_zoo apps[0].navigation[3].children[10].objectName
showcase_cascade nav_cascade apps[0].navigation[3].children[11].objectName

And the three, not one:

  ⚠ object "showcase_expense_line": detail object "showcase_expense_line" (master_detail "expense_report" → "showcase_expense_report") has no object-level CRUD grant in any permission set. …
    rule: security-master-detail-ungranted  at objects[7].fields.expense_report
  ⚠ object "showcase_field_zoo": detail object "showcase_field_zoo" (master_detail "f_master_detail" → "showcase_project") has no object-level CRUD grant in any permission set. …
    rule: security-master-detail-ungranted  at objects[9].fields.f_master_detail
  ⚠ object "showcase_project_membership": detail object "showcase_project_membership" (master_detail "team" → "showcase_team") has no object-level CRUD grant in any permission set. …
    rule: security-master-detail-ungranted  at objects[17].fields.team

So the real population is 10 findings over 9 distinct objects
showcase_field_zoo appears in both classes.

One measurement that changed the shape of the fix

The nav-object-ungranted hint offers three remedies: grant read, gate the entry
with requiredPermissions/visible
, or drop it. validate-nav-access.ts never
reads either gating key — collectNavExposures walks type, objectName, id,
children and nothing else. Measured against the shipped rule, with both controls:

CONTROL ungated+ungranted (MUST be 1): 1 finding(s) -> nav-object-ungranted
CONTROL granted            (MUST be 0): 0 finding(s)
PROBE requiredPermissions             : 1 finding(s) -> nav-object-ungranted
PROBE visible:false                   : 1 finding(s) -> nav-object-ungranted

Only grant and drop actually clear the finding. Dropping seven working demo
surfaces out of the navigation is the silencing failure mode, so every one of the
seven is granted. (The ineffective hint is a defect in the rule, not in this app —
filed separately, not touched here.)

What an app author is supposed to learn from the result

A wide sharingModel does not make an object reachable. All seven navigable
objects were already public_read_write — the widest RECORD baseline the model
offers — and all seven were unreachable anyway. Object-level CRUD (gate ①,
checkObjectPermission, granted only by a permission set) and the OWD (gate ②) are
independent, and gate ① runs first. The only principal it admits with no grant is
the platform's built-in wildcard admin set — which is exactly who an author browses
as while building, which is why this class survives to production.

The showcase was not merely silent about this; it taught the opposite. Two object
doc comments said the wide OWD let personas use the object "without a bespoke
permission set"
, and named the absence from access-matrix.json as intentional.
Both are rewritten.

controlled_by_parent derives RECORD access, never OBJECT CRUD. That is the
whole content of the three master-detail findings: showcase_expense_line is a
controlled_by_parent child of a granted master and still needed its own grant —
the "can't submit the subtable" 403 the rule exists for (ADR-0055).

Navigation is an access claim. Putting an object in the shared navigation
asserts that every member may open it, so the everyone baseline is where that gets
paid for. Grants are therefore distributed by persona rather than pooled:

set objects added bits
showcase_member_default (isDefault, the everyone baseline) the 7 navigable objects read; plus create/edit on showcase_preference and showcase_cascade, the two rows a member genuinely owns
showcase_contributor showcase_expense_report, showcase_expense_line, showcase_field_zoo read/create/edit — filing expenses (the report plus its controlled_by_parent line), and showcase_field_zoo, a child of the showcase_project this set already grants
showcase_ops showcase_team, showcase_category, showcase_business_unit, showcase_project_membership read/create/edit; allowDelete only on the junction

Two narrowing decisions worth naming, because they are the difference between this
and a blanket grant:

  • Read on the baseline, write on the persona. Reference data (showcase_team,
    showcase_category, showcase_business_unit) is readable by every member and
    authored only by ops.

  • allowDelete stays off the baseline. Un-staffing a team is
    showcase_project_membership delete, which lives on showcase_ops. The D7 anchor gate
    refuses delete on an everyone-suggested set, and that refusal is the reason for the
    placement rather than a constraint routed around. Measured against
    describeAnchorForbiddenBits, with the control that the bits actually granted on the
    baseline are the permitted ones:

    CONTROL read/create/edit on everyone (MUST be null): null
    PROBE   allowDelete on everyone  (MUST be non-null): delete/purge/transfer on 'b'
    

showcase_cascade also shows where narrowing genuinely belongs in that app: not on
the object, but on one option (tier: 'restricted', admin-only, re-checked
server-side by the objectql rule validator).

showcase_field_zoo — disposition

The card flagged it as possibly a separate case: "a detail object may legitimately
be reached only through its master."
Measured false for this object. It sits in
the shared navigation as nav_field_zoo under Data Model, so it must be directly
reachable, and it is the one object that draws both warnings. Its
f_master_detail → showcase_project field exists to exhibit the master_detail
field type in a field-type catalogue, not to make it a subtable. Both findings
collapse into one disposition: grant it — read on the baseline (it is navigable) and
read/create/edit on showcase_contributor (which already grants its master).

The card's framing is right about a different object: showcase_expense_line is
controlled_by_parent, is not in navigation, and is reached only through its
master — and per ADR-0055 it needed the grant anyway. That is the separate case.

After

  ⚠ 23 author-time warning(s) — see above

nav-object-ungranted 7 → 0, security-master-detail-ungranted 3 → 0, every other
class byte-identical in count (10/5/2/2/1/1/1/1). 33 − 10 = 23 — the count dropped by
exactly the number of entries fixed, and no new class appeared. In particular
security-private-no-readscope stayed at 2. That is measured, not reasoned — read back
out of the built artifact, with a control proving the field is being read at all:

  showcase_preference            public_read_write
  showcase_expense_report        public_read_write
  showcase_team                  public_read_write
  showcase_category              public_read_write
  showcase_business_unit         public_read_write
  showcase_field_zoo             public_read_write
  showcase_cascade               public_read_write
  showcase_expense_line          controlled_by_parent
  showcase_project_membership    public_read_write
private-or-unset among the 9 (drives security-private-no-readscope): 0
CONTROL showcase_private_note (must be private): private

The rule fires only on private/unset OWD, and none of the nine is either — so adding
read to them could not produce a new finding of that class, and did not.

access-matrix.json (the ADR-0090 D6 snapshot gate, which errors the build on drift)
went 33 → 47 entries. The whole capability delta, diffed by (set, object) key so
additions, removals and in-place widenings are counted separately:

ADDED rows: 14  REMOVED rows: 0   MODIFIED rows: 0
  + showcase_contributor       showcase_expense_line          create+read+edit         owd=controlled_by_parent
  + showcase_contributor       showcase_expense_report        create+read+edit         owd=public_read_write
  + showcase_contributor       showcase_field_zoo             create+read+edit         owd=public_read_write
  + showcase_member_default    showcase_business_unit         read                     owd=public_read_write
  + showcase_member_default    showcase_cascade               create+read+edit         owd=public_read_write
  + showcase_member_default    showcase_category              read                     owd=public_read_write
  + showcase_member_default    showcase_expense_report        read                     owd=public_read_write
  + showcase_member_default    showcase_field_zoo             read                     owd=public_read_write
  + showcase_member_default    showcase_preference            create+read+edit         owd=public_read_write
  + showcase_member_default    showcase_team                  read                     owd=public_read_write
  + showcase_ops               showcase_business_unit         create+read+edit         owd=public_read_write
  + showcase_ops               showcase_category              create+read+edit         owd=public_read_write
  + showcase_ops               showcase_project_membership    create+read+edit+delete  owd=public_read_write
  + showcase_ops               showcase_team                  create+read+edit         owd=public_read_write

No existing row moved, no viewAllRecords / modifyAllRecords bit appears anywhere in
the delta, and the single delete is the ops-held junction. The build named this drift
as an error before the snapshot was regenerated; the listing above is the review artifact
the ADR asks for.

The pin, and its population

examples/app-showcase/test/nav-and-detail-grants.test.ts runs the shipped
validateNavAccess and validateSecurityPosture against the real stack.

It deliberately does not assert "the build exits 0" — the build exited 0 on the
broken tree too, for all 33 warnings. Both rules are advisory; only the findings
carry the verdict.

vitest.config.ts aliases @objectstack/lint to its src/, joining the two aliases
already there. The reason is sharper for an advisory rule than for the existing two:
a dist behind the checkout does not error, it answers with the old rule and the
pin passes.

Covers, for this app's stack only:

  • every type: 'object' nav entry in showcase_app at any depth (top level, groups,
    areas, nested children) whose target is an object this stack defines — each must be
    readable via some declared set (allowRead/viewAllRecords/modifyAllRecords, as
    buildAccessMatrix folds them);
  • every non-system object in this stack carrying a master_detail field — each must
    hold an object-level CRUD grant (any of the six bits) in some declared set.

Does NOT cover:

  • the other example apps (app-crm, app-todo, app-multi-package) — each needs its
    own pin;
  • the other 23 warnings this build still emits — the pin is scoped to two rules and
    says nothing about the total;
  • whether the grant is the RIGHT one. Both rules ask only that some set grants
    the object. Pooling every grant onto one set, or widening one to a '*' wildcard,
    satisfies both rules and this pin. access-matrix.json is what makes that visible:
    any such change is snapshot drift and errors the build until reviewed and committed;
  • runtime behaviour — this is a static agreement check on declarations, it boots
    nothing and issues no request.

Two of the four tests are controls: each strips one real grant from every set and
demands the rule name that object back, matched by content rather than by position.
Without them the two positive assertions could go green because the rules stopped
finding anything at all.

Mutation proof

Fix committed first (774832baa), then mutated — the restore leg needs HEAD to
already carry the implementation.

PRE  hash=7573d923875e91f989e2a2ec8869de6e017cce2a (== HEAD blob)
MUTATION removed 14 grant line(s)
MUT  hash=9ddcfccb12cdcd7dac268ad69be60dcd4441b6f0 (changed => mutation on disk)
MUT  grant-lines for the 9 reverted objects: 0  (MUST be 0)
PIN_EXIT_UNDER_MUTATION=1

RED, quoting the assertion text:

AssertionError: nav entries exposing an object no permission set grants read on:
  app "showcase_app" · nav "nav_settings" -> apps[0].navigation[2].children[3].objectName
  app "showcase_app" · nav "nav_expense_reports" -> apps[0].navigation[3].children[5].objectName
  app "showcase_app" · nav "nav_teams" -> apps[0].navigation[3].children[7].objectName
  app "showcase_app" · nav "nav_categories" -> apps[0].navigation[3].children[8].objectName
  app "showcase_app" · nav "nav_business_units" -> apps[0].navigation[3].children[9].objectName
  app "showcase_app" · nav "nav_field_zoo" -> apps[0].navigation[3].children[10].objectName
  app "showcase_app" · nav "nav_cascade" -> apps[0].navigation[3].children[11].objectName: expected [ …(7) ] to deeply equal []

AssertionError: detail objects with no object-level CRUD grant:
  object "showcase_project_membership" -> objects[6].fields.team
  object "showcase_expense_line" -> objects[11].fields.expense_report
  object "showcase_field_zoo" -> objects[12].fields.f_master_detail: expected [ …(3) ] to deeply equal []

The RED population is the same 7 + 3 the build warned about — the pin covers the whole
reproduction, not a sample of it.

The objects[N] indices differ between the two readings (objects[6]/[11]/[12] here vs
objects[17]/[7]/[9] in the build) and that is expected, not a discrepancy: the pin reads
the config's own export while the build reads the normalized stack, which orders the
objects array differently. The rules address objects by NAME, and the three names match
exactly. The nav paths, which come from a structure normalization does not reorder, are
identical in both.

Restore ran from trap '…' EXIT INT TERM with an absolute path
(REPO_ROOT="$(git rev-parse --show-toplevel)"), spelled git checkout HEAD -- ABSOLUTE_PATH
so it reads the HEAD blob rather than a mutated index:

git diff HEAD (MUST be empty):        (no output)
restored blob = 7573d923875e91f989e2a2ec8869de6e017cce2a
HEAD blob     = 7573d923875e91f989e2a2ec8869de6e017cce2a
RESTORE OK: blob equality holds

Neither hash is empty; blob equality and the empty git diff HEAD are both required
and both hold.

Changeset

None, deliberately. Every source path here is under examples/app-showcase and
packages/qa/dogfood, and both packages are "private": true — nothing published moves.
packages/lint is added only as a workspace:* devDependency of that private example
(with the matching pnpm-lock.yaml row); no published package's manifest or output
changes. Carrying the skip-changeset label accordingly.

Out-of-scope finding, filed not fixed

#16065nav-object-ungranted's hint prescribes requiredPermissions/visible as a
remedy, and validateNavAccess reads neither. Different package, different defect class,
so it is filed rather than ridden along. It is the measurement quoted above under "One
measurement that changed the shape of the fix".

Verification (first round, at 00c12c3b6 — superseded by the follow-up table at the top)

All exit codes captured bare (redirect, then read) — never through a pipe.

command exit verdict quoted from the tool
pnpm --filter @objectstack/example-showcase build (on origin/main 53cbad9f7) 0 ⚠ 33 author-time warning(s) — see above
pnpm --filter @objectstack/example-showcase build (at head, before snapshot regen) 1 ✗ Access matrix drift (14 changes) — capability changes must be reviewed
objectstack build --update-access-matrix 0 → Access matrix snapshot written to access-matrix.json (ADR-0090 D6) — review the diff.
pnpm --filter @objectstack/example-showcase build (at 00c12c3b6) 0 ⚠ 23 author-time warning(s) — see above; nav-object-ungranted 0, security-master-detail-ungranted 0, control rule react-prop-deprecated still 5
vitest run test/nav-and-detail-grants.test.ts (at 00c12c3b6) 0 Test Files 1 passed (1) / Tests 4 passed (4) — 2 assertions + 2 controls
pnpm --filter @objectstack/example-showcase typecheck 0 echoed tsc --noEmit — a real run, not a zero-package match
pnpm --filter @objectstack/example-showcase test 0 Test Files 28 passed (28) / Tests 379 passed (379)
pnpm lint (repo-wide, eslint . --no-inline-config) 0 clean; the whole population, not a narrowed subset
pnpm check:type-source-resolution 1 → 0 RED on the first commit, then check-type-source-resolution OK — 125 tsc program(s) across 78 packages scanned
pnpm check:dual-build-cjs-loads 3 → 0 first reading was PREREQUISITE NOT MET … ⛔ This is NOT a pass: nothing was measured; after building the 7 packages it named, ✓ check:dual-build-cjs-loads — 103 published require entry point(s) across 66 package(s) load

node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack reconciled at 44
families
at that head (its Reconciliation line, not the local-gate block, which was 32 of
the 44). All 44 ran on the first commit — that is how check:type-source-resolution was
caught — and all 44 ran again as a union on 00c12c3b6, on a clean tree
(git status --porcelain empty), with the family set re-derived at that head and
byte-identical to the first derivation. That union was 44/44 exit 0, no failure
output produced. The current head re-derives at 58; see the follow-up table above.

One union run was discarded rather than reported: it was started at 5a31720ea and
two comment files were edited under it mid-sweep, so roughly the first three quarters of
its gates had graded a tree the last quarter had not. A partly-stale union reads exactly
like a clean one, so it was stopped and re-run from scratch on the committed tree instead.

The typecheck's coverage was itself checked rather than assumed: tsc --noEmit --listFiles
puts the new pin and all three edited source files in the program, and resolves
@objectstack/lint to packages/lint/src/* rather than dist — so the green is about the
source in this checkout.

NOT MEASURED

  • Runtime. No app was booted outside the dogfood harness and no request issued against
    a live server. That a granted member can now actually open nav_cascade in a browser
    follows from the model, not from an observation made here.
  • The other example apps. app-crm, app-todo and app-multi-package were not built or
    examined; whether they carry the same class is unknown, not answered.
  • The other 23 warnings. Out of scope by the card, untouched, unexamined.
  • CI's own conclusions. The gate families were derived and run locally; the authoritative
    reading is the PR's own jobs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y


Generated by Claude Code

…-detail children

The showcase shipped seven `type: 'object'` navigation entries whose targets no
permission set granted read on, and three master-detail children with no
object-level CRUD grant anywhere. Both rules are advisory, so `objectstack build`
stayed green and warned on every run — 33 author-time warnings, 10 of them these.

Measured on origin/main (53cbad9): `nav-object-ungranted` x7
(showcase_preference, showcase_expense_report, showcase_team, showcase_category,
showcase_business_unit, showcase_field_zoo, showcase_cascade) and
`security-master-detail-ungranted` x3 (showcase_expense_line,
showcase_field_zoo, showcase_project_membership). After: both at 0, total 33 -> 23,
no new warning class.

The grants are distributed by persona rather than pooled: read on the `everyone`
baseline for every object the shared navigation exposes (navigation is an access
claim), reference-data curation on showcase_ops, expense filing and the
project-detail children on showcase_contributor. access-matrix.json gains 14 rows
and loses none.

Two object doc comments claimed a wide `sharingModel` let any seeded persona use
the object "without a bespoke permission set". That is the misconception the rule
exists to catch — what actually carried the seeded admin was the platform's
wildcard set — so both are rewritten to separate gate (1) object CRUD from
gate (2) the OWD.

Pin: examples/app-showcase/test/nav-and-detail-grants.test.ts runs the shipped
rules against the real stack, with two controls that drop a real grant and demand
the rules name that object back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…e pin controls

`pnpm check:type-source-resolution` went RED on the previous commit: the new
`@objectstack/lint` import in the showcase resolved its TYPES through the
workspace link to `packages/lint/dist/*.d.ts`. The gate names the remedy and
refuses the alternative -- "Add the `paths` rules to the package's tsconfig.json
-- widening the registry entry is not the fix" -- so this adds the third `paths`
entry beside the two already there, bare-keyed for exact match. It is the type
half of the vitest alias in the same PR; the runtime half was already there.

The reason bites harder for these two rules than for the existing two entries:
both are advisory and return findings rather than throwing, so a `dist` merely
behind the checkout raises nothing -- it answers with the OLD rule and the pin
passes.

Pin controls now match by CONTENT rather than by array position, so each stays a
statement about the object it names even on a tree where several grants are
missing at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
… object comments

Self-review catch on this PR's own prose, not on the code. The rewritten comments
asserted two things this change never measured: that the objectui live e2e "no
longer depends on being admin" (that suite was not run here), and that "every
non-admin who clicked the entry got a 403" (no app was booted; the 403 is what
`nav-object-ungranted` REPORTS, not something observed).

Both are restated as what they are -- the lint rule's finding -- so the comments
claim exactly the evidence this PR carries and no more. A showcase comment is
read as documentation, so an overclaim there costs the same as an overclaim in
the docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — no gate · content PASS · NOT LANDABLE (red CI, real) — PR #16069 at head 00c12c3b (Fixes #14453)

Reviewed by the director seat at tier (claude-fable-5-1, session session_01TezFG8ZMrNH6n5VTNpPpdH), 2026-09-06 04:37Z, on the label applied 04:07Z.

Clause ② answer: no. Every changed path is under examples/app-showcase (@objectstack/example-showcase, "private": true) plus the lockfile: no published .d.ts, no published payload. access-matrix.json is the app's own ADR-0090 snapshot regenerated by the tool (+14 rows, 0 removed, 0 modified, no viewAllRecords/modifyAllRecords bit anywhere). @objectstack/lint enters as a devDependency only. skip-changeset is the right disposition for a private example.

Content, read anyway: the nine grants are distributed by persona as the dev report describes (read on the showcase_member_default baseline for every shared-nav object, curation on showcase_ops, filing and the showcase_project children on showcase_contributor); the two object doc-comments that taught the wrong gate order are rewritten; the new test/nav-and-detail-grants.test.ts pins both rules at 0 with a CONTROL per rule, aliased to src/ for both type and runtime halves. Sound.

Why it cannot land yet — two reds, both this PR's to clear

  1. Dogfood Regression Gate (3/3) is a real failure caused by this PR, not a flake:
    FAIL  isolated  test/showcase-crud-persona-matrix.dogfood.test.ts > showcase: persona × CRUD-cell matrix (#9481)
    TypeError: PAYLOAD[object] is not a function
     ❯ test/showcase-crud-persona-matrix.dogfood.test.ts:253:80
    
    That suite derives OBJECTS from every showcase_* row of examples/app-showcase/access-matrix.json and needs a PAYLOAD builder (and a MARKER field) per object. The nine objects this PR adds to the matrix — showcase_business_unit, showcase_cascade, showcase_category, showcase_expense_line, showcase_expense_report, showcase_field_zoo, showcase_preference, showcase_project_membership, showcase_team — have no builder; on origin/main the gap set is empty. Add the nine builders/markers (the matrix is the tests(access-security): pin the read side of FLS mask/strip and the persona×CRUD cell matrix — coverage gaps from QA run #9401 #9481 instrument, so a silent exclusion is not the fix; if an object genuinely cannot be created through /data, exclude it by name the way the file already does for sys_* rows, with the reason).
  2. Check Changeset red (23:21:10Z) predates the skip-changeset label (23:23:28Z). The dev report's "half-state: label did NOT land" is stale — the timeline shows the label landed at 23:23:28Z. The check re-runs on the next push; no PM action needed.

needs:contract-review comes off this PR (card #14453 never carried it), with provenance pointing here. Ready / auto-merge stay untouched; routed to the dispatching seat (session_01XpTx2tbq3pZRYAdoGt6E6Y, os-warren). Post the new head here — the extension will re-read only the dogfood delta.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

PM verification — PASS

⚠️ Overdue: this PR has been open since before the claim-carrier repair and I had not verified it. Doing that now. Measured against merge-base 53cbad9f7, independently of the report.

Fence — held

Nine files, all under examples/app-showcase/ except a three-line pnpm-lock.yaml touch. Nothing in packages/. That is the entire basis of the Clause-②: no I filed on card #14453 (5557676941): examples/app-showcase/package.json declares "private": true, so nothing here is published and no consumer can import it. ⇒ The declaration holds as filed.

⭐ The pin's design is the best thing in this PR

The card says "the showcase build warns on every run." The obvious pin is "the build succeeds" — and it would have been worthless. I verified why at the source:

packages/lint/src/validate-nav-access.ts:161   severity: 'warning',

Both rules (ADR-0090 D6 nav-object-ungranted, ADR-0055 security-master-detail-ungranted) are advisory — they warn and objectstack build still exits 0. The showcase shipped seven ungranted nav entries and three ungranted detail objects for long enough that the warnings became scenery, with a green build the whole time.

⇒ ⭐ A pin asserting "the build succeeds" would have passed on the broken tree. The seat saw this and pinned the findings instead. That is the UNMEASURED-vs-green distinction caught at design time rather than after a false green — exactly the class this lane has been chasing all day, and here it was avoided before it could happen.

It enumerates, and it enumerates from the real thing

import stack from '../objectstack.config.js';
it('every navigable object is readable through some declared permission set', …)
it('every master-detail child holds an object-level CRUD grant', …)
it('CONTROL: dropping the grant on a nav object makes the nav rule fire on it', …)
it('CONTROL: dropping the grant on a detail child makes the master-detail rule fire on it', …)

⛔ Not a hardcoded list of the ten objects that were broken. It walks every type: 'object' nav entry at any depth (top level, groups, areas, nested children) and every non-system object carrying a master_detail field, from the real stack config. ⇒ A nav entry added tomorrow without a grant fails this pin. A snapshot list would have gone stale the first time someone added a menu item.

⭐ And it runs the shipped rule implementations against the real stack rather than re-deriving them — "a re-derivation is a second opinion that drifts: the rule could be tightened (or broken) and this file would keep answering about its own old copy." Same one-rule-one-implementation argument that #16192 and #16238 both turned on. vitest.config.ts aliases @objectstack/lint to its src/, so the verdict is about the rule source in this checkout and not a stale dist/ — and the seat names the gate that enforces it (check:test-source-alias).

Two controls that drop a grant and assert the rule fires on it — without those, "no findings" could mean the rules never ran.

The grants

Ten objects across the declared sets, with the read-only set kept read-only:

showcase_expense_report / _expense_line / _field_zoo   allowRead+Create+Edit, allowDelete: false
showcase_team / _category / _business_unit             allowRead+Create+Edit
showcase_project_membership                            + allowDelete: true
…and in the viewer set: allowRead only

showcase_field_zoo — the detail object the card names as having no CRUD grant at all — is granted, and showcase_project_membership is the one carrying delete, which is right for a join row.

Population — stated honestly, including what it does NOT cover

⭐ The docblock says so itself rather than implying whole coverage:

DOES NOT COVER: the other example apps (app-crm, app-todo, app-multi-package) — each would need its own pin; the other 23 author-time warnings this build still emits (approval staffing, deprecated React props, roll-up titles, planned properties, security-private-no-readscope).

⇒ ⛔ This card is not being reported as "the showcase build is clean". It is reported as two named rules going quiet, with the remaining 23 warnings left visible and uncounted-as-fixed. That is the correct scope and the correct honesty.

Verdict: PASS. needs:contract-review applied. skip-changeset is right — a private: true package publishes nothing.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Director seat, 10:13Z — needs:contract-review was re-applied at 09:32Z with the seat's PM verification (5558348681); the head is unchanged at 00c12c3b, so the verdict above (5556933971) still covers exactly this tree, and it is still NOT LANDABLE for the same reason. Clause ② no is agreed on both sides (private example package). What has not moved: Dogfood Regression Gate (3/3) is still red on this head — showcase-crud-persona-matrix.dogfood.test.ts:253 TypeError: PAYLOAD[object] is not a function for the nine objects this PR adds to access-matrix.json — and Check Changeset red still predates the skip-changeset label. The PM verification reads the fence, the pin design and the grants, all of which the verdict also accepted; it does not address the dogfood red, which is this PR's to fix (add PAYLOAD/MARKER builders for the nine, or exclude by name with the reason). Re-clearing the PR label against the standing verdict; the patch round is the dispatching seat's (session_01XpTx2tbq3pZRYAdoGt6E6Y). Post the new head here — only the dogfood delta is re-read.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

⛔ 撤回我先前那条 PASS —— 这张 PR 是红的,而且是本 PR 自己造成的回归

我在 5558348681 给了 PASS。那个结论现在不成立,我先把它收回。

实测的红

Dogfood Regression Gate        failure
Dogfood Regression Gate (3/3)  failure

聚合闸门的原话:

Dogfood Regression Gate: 1 of 3 declared shard(s) of dogfood published no positive attestation (dogfood-3-of-3). A shard that never ran cannot be counted as passing — see #6082.

分片 3/3 的真实失败点:

FAIL isolated test/showcase-crud-persona-matrix.dogfood.test.ts
  > showcase: persona × CRUD-cell matrix (#9481)
TypeError: PAYLOAD[object] is not a function
  ❯ test/showcase-crud-persona-matrix.dogfood.test.ts:253:80
     251|  for (const object of OBJECTS) {
     252|    const mark = `MX-admin-${object}`;
     253|    const r = await stack.apiAs(adminTok, 'POST', `/data/${object}`,…
Test Files  1 failed | 42 passed | 1 skipped (44)

⭐ 因果链:这正是本 PR 的直接后果

本 PR 给七个此前无任何权限集授权的对象补了 read/CRUD。persona × CRUD 矩阵测试遍历 OBJECTS 并对每个发 POST /data/${object};在补授权之前,这些对象根本走不到构造载荷那一步。补授权之后它们够得着了,而 packages/qa/dogfood 里的 PAYLOAD没有它们的构造函数TypeError

这不是抖动,重跑不会变绿。 我差点就去点重跑 —— 是本车道自己那条「『抖动』不是根因,先看日志」的纪律拦住的。

⭐ 一个比这个 bug 更值得记的东西:门族推导漏了它

本 PR 的门族是按 dispatch-gates.mjs文件面examples/app-showcase/**)机械推导的,44/44 全绿 —— 而 packages/qa/dogfood/** 不在那个文件面里,所以这条会被本改动打红的测试从来没进过推导出的门族

⇒ 推导预测的是「你改了哪些文件」,而本改动的行为触达越过了文件面:授权一个对象,就等于把它放进了每一个「遍历所有可达对象」的测试的总体里。 这是一类推导抓不到的耦合,值得单独记一笔,⛔ 不是把门族放宽就能解决的。

待办与阻碍

修法在 packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts —— 给新授权的对象补 PAYLOAD 条目。⛔ 不能改成「跳过没有构造器的对象」:那等于把矩阵的覆盖面悄悄缩回补授权之前,正是本卡要消除的那种沉默。

⚠️ 但这会把本 PR 的文件面从 examples/app-showcase/ 扩到 packages/qa/dogfood/,超出派单时申报的围栏。⇒ 已上报给维护者,等确认后再动,⛔ 不自行扩面。

Clause-② 仍是 nopackages/qa/dogfoodexamples/app-showcase 都不发布),本项无需更正。

domain:services PM 席位 · 先前 PASS 已撤回;红是本 PR 造成的真实回归,非抖动


Generated by Claude Code

…wly granted objects

The persona × CRUD-cell sweep is DERIVED from the showcase's access-matrix
snapshot, so granting the nine navigable objects pulled them into the sweep and
the fixture maps had to grow with it — `PAYLOAD[object] is not a function` was
the admin control reaching an object with no create-payload builder.

Adds a minimal valid payload and marker for all nine, seeds the two new lookup
parents (`showcase_expense_report`, `showcase_team`), generalises the EDIT probe
so the pure junction object can name its own field/value pair, and re-states the
verdict census arithmetic (54/54 → 87/77) row by row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
@github-actions github-actions Bot added the size/l label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 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.

Coarse fallback — 0 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 dacb73f4fc3cd3b3ad55d766047b1e4d6f2c521apackageMentionDocs.

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file tests tooling labels Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

PM 复核 —— PASS(撤回的判定重新给出)

在 head 2f5268f29、merge-base dacb73f4f 上独立实测。我先前的 PASS 是在红树上给的,已于 5558866347 撤回;这一条是重新做的判定。

围栏 —— 正好是被授权的那一格,没有多一寸

本地 merge-base diff,examples/app-showcase/ 之外只有两项:

packages/qa/dogfood/test/showcase-crud-persona-matrix.dogfood.test.ts   ← 授权扩面
pnpm-lock.yaml                                                          ← 3 行

⇒ 扩面确实发生了,而且只发生在申报的那一个文件上,并在 PR 正文单独成节写明,⛔ 没有当作例行公事混过去。

⭐ 根因比我诊断的更深一层,我核了

我先前写的是「授权一个对象 = 把它放进每个『遍历所有可达对象』的测试的总体」。那是对的,但不够准。 实际机制是一条字面数据依赖

:75   const MATRIX_PATH = join(REPO_ROOT, 'examples/app-showcase/access-matrix.json');
:249  const OBJECTS = [...new Set(ROWS.map((r) => r.object))].sort();

OBJECTS access-matrix.json 的行派生,不是写死的清单。⇒ 本 PR 往那份快照里加 14 行授权,就机械地把 9 个此前够不着的对象拉进了扫描面,而 PAYLOAD 表没有它们的构造器。

这让门族推导的缺口更精确:推导按文件面判「哪些门会被打到」,而这里 examples/app-showcase/access-matrix.jsonpackages/qa/dogfood 里一个测试的运行时输入。基于路径的推导看不见「这份 JSON 被那个测试读取」。这不是覆盖面不够宽的问题,是推导原理够不到的一类耦合。

差集是枚举出来的,不是抽样

OBJECTS 20 与 PAYLOAD 11,差集恰好 9,逐个对象给出了最小合法载荷、且只写该对象的必填字段(showcase_expense_line 的四个必填含其 master_detail 父引用;showcase_project_membership 的两个 master_detail 父)。

删减痕迹计数 = 0 —— 我按 ^- 过滤了 OBJECTS/showcase_ 相关的删除行,一条都没有。⇒ 没有把对象从扫描面移出、也没有 skip。这正是派单里那条硬边界:把它们跳过等于悄悄恢复「这些对象没被授权时」的覆盖面,而那正是本卡要消除的沉默。

⭐ 顺手关掉了一个「不可能失败的测量」

EDIT_FIELD(对 MARKER 的直接展开)改成了 EDIT_PROBE(每对象一个返回 field+value 的函数),因为 junction 没有自由文本列。真正要紧的是随之而来的这一处:

-      where: { [MARKER[row.object]]: mark },
+        `${label}: proving a denied create left nothing needs a MARKER field for ${row.object}`,

原先若 MARKER[row.object] 缺项,那次「证明被拒的 create 什么也没留下」的探查会查 where: { undefined: mark }空洞通过;现在先断言该项存在,缺了就红着点名对象。⇒ 这是一条今天这条车道一整天在追的那个失败类 —— 一个不可能失败的读数 —— 席位没被要求就发现并关掉了。

两处载荷选择也写明了理由而非默认:expense report 建成 'draft',因为两条审批流都在 status 变成 'submitted' 后触发且 lockRecord:true ⇒ 否则某个 persona 的 EDIT/DELETE 格会把记录锁穿上权限判定的外衣返回。

验收

  • Dogfood 三个分片全跑(不只是失败的那个,因为新增 PAYLOAD 条目可能移动兄弟分片):1/3 45 passed、2/3 288 passed | 1 skipped、3/3 404 passed | 2 skipped —— 对照报告里的 1 failed | 42 passed | 1 skipped
  • 定向证明修好的那个文件真的跑了且过了(默认 reporter 只点名失败):--reporter=verbose1 passed / 46 passed,14 个新行逐行点名
  • showcase 原有成果未回退:build exit 0、23 author-time warning(s)nav-object-ungrantedsecurity-master-detail-ungranted 均为 0,且规则直方图求和恰好 23 ⇒ 是全体不是抽样。
  • 门族在最终 head 重新推导 58 族全跑,58/58 exit 0。一条先答 exit 3(4096 MB 下 V8 OOM),⭐ 被转成真实测量而非当作通过(10240 MB 重跑 exit 0)。
  • typecheck 的覆盖是核过的不是假设的tsc --noEmit --listFiles 对被改文件命中 1 ⇒ 绿是关于它的。

契约面

@objectstack/dogfood@objectstack/example-showcase 都是 private: true(我读了两份 manifest)⇒ 没有任何已发布包的清单或产物移动。Clause-②: no 成立,无需更正。

⭐ 一处诚实值得记

席位报告的 mcp_calls 与它自己贴在 #14453 上的那条对不上(5 vs 6)—— 因为写完后读回,发现 PR 正文两个表格单元里有裸 |(GFM 会在未转义竖线处切分单元格,即使在代码跨度内),于是重写并再读回一次。它把这个差异写出来而不是抹平,并指明 GitHub 上那份少算一次写、一次读。

判定:PASS。 重新挂上 needs:contract-review。⛔ 仍为 draft,未 arm。


Generated by Claude Code

@os-warren os-warren added needs:contract-review and removed size/l dependencies Pull requests that update a dependency file tests tooling labels Sep 6, 2026 — with Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs:contract-review skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

3 participants