Skip to content

feat(spec): a metadata-form row each for object.access, highlightFields, requiredPermissions, searchableFields and permission.adminScope (#20349) - #20405

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20349-g1a-object-permission-rows
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20349-g1a-object-permission-rows

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20349
Flight G1a of #19332 (ruling 5861442317).

Clause-②: no

What

Five live keys that the object and permission-set schemas declare had no form row, so an author could reach them only through the Source tab. Each now has exactly one row. Each row copies a row that a registered form already has for the same node shape. The four-locale catalogue rows are in the same PR.

key form, section control the row it copies
object.highlightFields object.form.ts, Basics (beside nameField) widget: 'string-tags' view.form.ts searchableFields
object.searchableFields object.form.ts, Basics widget: 'string-tags' view.form.ts searchableFields
object.access object.form.ts, Advanced (beside sharingModel) type: 'composite' over one declared default select (public / private) object.form.ts lifecycle
object.requiredPermissions object.form.ts, Advanced widget: 'json' object.form.ts validations
permission.adminScope permission.form.ts, System Permissions widget: 'json' permission.form.ts objects / tabPermissions / rowLevelSecurity

The ruling's widget rules, as applied here:

  • requiredPermissions is the one union in this flight: string[] or a strict {read, create, update, delete} map. It takes json and never string-tags.
  • The two field-name lists take string-tags, as free text.

The help text says what the runtime does with each value, including what absence resolves to. Each claim was checked against its reader:

  • access absent means public: security-plugin.ts reads isPrivate as access?.default === 'private'.
  • An empty requiredPermissions skips the capability gate: security-plugin.ts normalizes it to empty buckets.
  • A dangling highlightFields entry is refused at error on the object publish door: validateObjectFieldRefs, runtimeTypes: ['flow', 'object'].
  • A dangling or virtual searchableFields entry is refused at error: validateSearchableFields, including object.

No schema's accepted input changes, and no export changes. What changes is the form payload that getMetaTypes() serves and the translation keys that os i18n extract walks.

The ruling's objectui premise, re-checked at the .objectui-sha pin

The ruling carries this premise for the first form flight to re-check: "string-tags reads a non-array as []; field-multi binds nothing on an object draft". I read objectui f8a9d0fb0596f4521076628e2bbfe27e6ce67d52 (the pin on main) from source. No browser was run. Both halves hold.

  • string-tags — packages/app-shell/src/views/metadata-admin/widgets.tsx:1290: the widget sets tags to the value when the value is an array, and to an empty array otherwise. add and remove then write next, which is built from tags, back through onChange. So a stored map would be replaced by a list on the first edit. HOLDS.
  • field-multi — ResourceEditPage.tsx:889-893: the field catalogue's source object comes from interfaceConfig.source, data.object, object or objectName on the draft. The object type's served JSON schema declares none of those four (probe on this tree: 43 top-level keys, all four absent, lit control name present). So the picker would offer an empty list. HOLDS.

Also read at the pin, because the new rows depend on it: resolveFieldFace in SchemaForm.tsx:667-750.

  • json is not a registered widget. It is in KNOWN_PASSTHROUGH_WIDGETS, so the face comes from the node after resolveUnionBranch.
  • For requiredPermissions, the served node is an anyOf of array-of-string and an object with four properties.
    • A stored map resolves to the object branch, which renders as a nested form.
    • A stored list, or a create with no value, resolves to the array branch, which renders as the scalar list control.
  • adminScope is served inline as an object with properties, so it renders as a nested form.
  • Nested-form edits merge. setField spreads the stored value and writes only the one key, so no key the author did not touch is rewritten.
  • Schema defaults are placeholders and are never written on mount. So an untouched adminScope stays absent.

Residue of the reconciliation gate

The gate's own helper block (metadata-form-zod-reconciliation.test.ts, the whole file copied into a scratch probe that was never committed) was run at the root coordinate over every object-rooted type. It counts offerable keys, minus offered keys, minus root omit rows. The probe asserted two controls in the same run:

  • lit control: field.accept, a G1b key, is in the residue on both trees;
  • dark control: the fabricated key object.zzFabricated20349 is in no residue.
tree residue per type
merge base 15bf186f5 28 object 9 · field 12 · action 6 · permission 1
this branch (same src/ as head 353e708a) 23 object 5 · field 12 · action 6 · permission 0

Removed: object.access, object.highlightFields, object.requiredPermissions, object.searchableFields, permission.adminScope. Added: none. Both probe runs: 1 file, 58 tests passed.

Verification

All test runs went through scripts/pm/os-verify-lock.sh, and each reported VERDICT command-exit 0.

run result
pnpm --filter @objectstack/spec test Test Files 557 passed (557) · Tests 16497 passed, 1 todo (16498)
pnpm --filter @objectstack/spec test:repo Test Files 35 passed (35) · Tests 634 passed (634)
pnpm --filter @objectstack/platform-objects test Test Files 55 passed (55) · Tests 911 passed (911)
pnpm --filter @objectstack/spec typecheck exit 0 (tsc --noEmit, check:scripts-typecheck, check:test-typecheck OK: 53 files, 255 errors, 142 signatures held by the ledger)
pnpm --filter @objectstack/platform-objects typecheck exit 0 (check:test-typecheck OK: 1 file, 3 errors, 2 signatures held by the ledger)
lint src/validate-predicate-path-refs.test.ts (reads the object form) 1 file, 54 tests passed
cli unit test/i18n-coverage.test.ts (reads the form registry) 1 file, 20 tests passed
pnpm check:i18n (after the closure build it names) all 9 packages in sync; the catalogues regenerate byte-for-byte from the forms
pnpm --filter @objectstack/spec check:generated all 15 generated artifacts up to date

Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 85 commands. All 85 were run, and each exit code was written to disk before it was read. --ran reports: 85 derived, 85 run, 0 NOT-MEASURED, 0 UNRUN.

  • check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: nine packages had no dist/). The missing dist/ directories then existed, and the re-run exited 0. The re-run's code is the one recorded.
  • check:entry-nameability prints its standing structural NOT MEASURED line for two entries with no callable export. That line is independent of this diff.

No ablation was run. This PR adds rows and moves population pins; it adds no guard. The residue before/after above is the measurement of the delta.

Hand-over account

This branch was resumed from bc1d395d3, the previous dev run's pushed head. It was re-read hunk by hunk and re-verified in full.

  • 7ce01726 (the five rows): kept. Every help-text claim was checked against its runtime reader, and every comment claim about the renderer was checked at the pin (above).
  • 3820f4a7 and 3ae44a78 (catalogue rows and re-extract): kept. After the merge, pnpm check:i18n regenerates them byte-for-byte. The source-hash rows the first commit added were removed by the second, net zero.
  • abbf9c7f (echo-decision pins and changeset): kept. The pins were already complete: the platform-objects suite passes on the merged tree with no further pin edit.
  • 353e708a (this run): the changeset lacked the Clause-②: no line. It now carries that line, and it names the pin that the renderer description is read at.
  • e505f724: merge of origin/main via scripts/pm/os-regen-merge.sh. No os-regen path conflicted, and nothing needed regenerating (check:generated is green).

Acceptance notes

  • The ruling says "a misspelling is refused loudly at parse". The refusal is at the publish door and in os validate (the two lint rules above, at error), not in the Zod parse: ObjectSchema does not judge field names. The changeset says so.
  • metadata-form-zod-reconciliation.test.ts has a dark-control comment reading "object.access is authorable and no form offers it". After this PR a form offers it. The assertions under that comment still hold, because they test ruled-class admission and not whether a key is offered. That file is the ledger, which is outside this flight's surface. Carrier: the #19188 split: 145 top-level zod-only keys need a RECORDED REASON, never a form row — and none can be recorded until the ledger learns a root path #19333 item 2 wiring, which edits that file.
  • Nothing pins the ruling's rule that union values never take string-tags across the registry. A future row could flip requiredPermissions back without any test turning red. This is not a defect today. It is noted for the #19188 split: 145 top-level zod-only keys need a RECORDED REASON, never a form row — and none can be recorded until the ledger learns a root path #19333 wiring, which is where a registry-wide union check would live.
  • A nested-form face cannot unset an object key. Once an author types into adminScope, clearing every sub-field leaves an empty object, and the parse then refuses it loudly because businessUnit is required. The failure is loud, it comes from the generic objectui renderer, and it is the same for every nested-form row. Carrier: none.

Generated by Claude Code

…missions, searchableFields and permission.adminScope

Five live keys the object and permission-set schemas declare had no form
row, so their only door was the Source tab. Each gets one row mirroring a
row a registered form already has: string-tags for the two field-name
lists (the view form's searchableFields row), a composite over a select
for access (the lifecycle row), and json for the requiredPermissions
union and for adminScope (the validations row and the permission form's
structured rows).

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
…nslated in zh-CN, ja-JP and es-ES

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
… of the provenance tables

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
…ive new rows; changeset

Claude-Session: https://claude.ai/code/session_01CiCTczDo7tGhafXjf61dUJ
Co-authored-by: Claude <noreply@anthropic.com>
…the objectui pin the renderer reading is taken at

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/platform-objects, @objectstack/spec, touching 5 documentable anchor(s).

33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json df3ba164a588805c6a3b07ec8d91c94737c47b52.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see

Coarse fallback — 136 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 df3ba164a588805c6a3b07ec8d91c94737c47b52 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json df3ba164a588805c6a3b07ec8d91c94737c47b52

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 353e708aa7808c39a649d8254cf2895a59f9f648
Local-runs: none

Inputs read: card #20349 (body and all 4 comments, the os-dev-report 5865711583 included), PR #20405 (body, 9-file list, application/vnd.github.diff against main, cross-checked byte-equal to git diff 15bf186f5..353e708a apart from abbreviated index hashes), the 35 check-runs on the head, ruling 5861442317 and the analysis-round table 5859943900 on #19332 (the card's acceptance names the latter for the per-key shapes). Source readings are git show/git grep against fetched refs only. The objectui sibling checkout at /home/user/objectui was read with git show at the .objectui-sha pin f8a9d0fb0596f4521076628e2bbfe27e6ce67d52 (the pin at this head) for the widget-bound premise. Check-runs: 32 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failure, 0 in_progress; all seven required contexts success; Check Changeset, Spec property liveness and Governed Surface Queue Guard success. No governed-surface path in the file list.

① Derived judgments

Each row against its key's schema shape at the head, the ruling's widget rule, and the row it claims to mirror:

  • object.highlightFields — widget: 'string-tags', Basics. Schema z.array(z.string()).optional() (object.zod.ts:2162); not a union. Mirrors view.form.ts:80 searchableFields (widget: 'string-tags'). RIGHT.
  • object.searchableFields — widget: 'string-tags', Basics. Schema z.array(z.string()).optional() (object.zod.ts:2229); not a union. Same mirror. RIGHT.
  • object.access — type: 'composite' over one declared default select (public / private), Advanced beside sharingModel. Schema ObjectAccessConfigSchema is a strict object whose only member is default: z.enum(['public','private']).default('public') (object.zod.ts:711-745); both option values satisfy the FormSelectOptionSchema.value identifier pattern. Mirrors the lifecycle composite (object.form.ts:488). RIGHT.
  • object.requiredPermissions — widget: 'json', Advanced. Schema is the one union in the flight: z.union([z.array(z.string()), PerOperationRequiredPermissionsSchema.strict()]) (object.zod.ts:760-775). The ruling's rule (union takes json, never string-tags) is honoured; mirrors the validations row (object.form.ts:424, widget: 'json'). RIGHT.
  • permission.adminScope — widget: 'json', System Permissions. Schema AdminScopeSchema is a strict object with six keys (permission.zod.ts:654-702); not a union. Mirrors the form's own objects / fields / tabPermissions / rowLevelSecurity json rows, which is the mirror the analysis table named. RIGHT.
  • Exactly one row per key on the head forms (grep count 1 for each of the five); no duplicate.

Help-text claims, each against its runtime reader at the head — no row claims a reading that does not exist:

  • access: "Absent resolves to public" — security-plugin.ts:8438 isPrivate: access?.default === 'private', and the schema's own .default('public'). "exempt from wildcard row-level security" — the schema's describe and TSDoc (object.zod.ts:700-706) and the posturePermits superuser bypass (security-plugin.ts:6838). RIGHT.
  • requiredPermissions: "Absent or empty: no capability gate" — normalizeRequiredPermissions (:573-590) yields empty buckets and the AND-gate runs only when required.length > 0 (:2287-2290); "permission-set systemPermissions" — the gate reads getSystemPermissions(permissionSets); "a list gates every operation; a map gates only the operations it lists" — requiredCapsForOperation (:598-606). RIGHT.
  • highlightFields: the reader list is the schema's own describe (object.zod.ts:2162) and objectui readers exist at the pin (ObjectView.tsx, InterfaceListPage.tsx, RecordDetailView.tsx, deriveLookupColumns.ts, LookupField.tsx, record-title.ts). "refused at publish" — object-field-ref-unknown at error (validate-object-field-refs.ts:279), suite member runtimeTypes: ['flow','object'] (reference-integrity-suite.ts:348), suite entry surfaces: ['cli','runtime-publish'] with runtimeTypes including object (authoring-rules.ts:955-956), dispatched by runtimeAuthoringRulesFor into evaluateRuntimeAuthoringGate which throws 422 INVALID_METADATA (metadata-protocol/runtime-authoring-gate.ts:576, 799-800). RIGHT.
  • searchableFields: "an unknown name or a virtual formula field is refused at publish" — searchable-field-unknown and searchable-field-unsearchable, both error (validate-searchable-fields.ts:338, 405, 434, 466), member runtimeTypes: ['flow','view','object'] (:261). "Unset, search uses the name/title field plus short-text fields" — resolveSearchFieldResolution falls to autoDefaultFields (spec/data/search-fields.ts:127-185), which leads with the display field over the textual types; it also admits enum-typed fields, so the sentence under-states the fallback by one type family. It copies the schema's own describe verbatim and asserts no reading that does not exist. RIGHT, nit noted (a describe-level under-statement, not this PR's).
  • adminScope: required non-blank businessUnit (sys_business_unit.name), includeSubtree default true, the three booleans default false, the assignablePermissionSets allowlist — all six match AdminScopeSchema; "may hand out only the sets named" — delegated-admin-gate.ts:766, 800, 849, 893. "Leave empty for a set that delegates nothing" mirrors the schema's own refusal prose ("remove adminScope if this permission set should not delegate administration") and the form's packageId / lifecycle idiom. RIGHT.

Accept set and public surface: no *.zod.ts, no export, no error-code ledger touched; the two *.form.ts files are content of the opaque METADATA_FORM_REGISTRY; check:api-surface and check:authorable-surface ran green inside TypeScript Type Check. The accept set does not move — RIGHT. What moves is the served form payload and the translation keys: the four *.metadata-forms.generated.ts bundles add 12 en leaves (6 rows × label + helpText), each hand-authored in zh-CN, ja-JP and es-ES with machine tokens kept verbatim; the six en help texts are byte-equal to the form sources; the "access.default" sub-row key matches the "lifecycle.class" precedent; check:i18n ran green inside Lint & Repo Gates. The two echo-decision pins move by exactly the new leaves (advanced 46→52 and 55→61; open 96→100; translated labels 608→614 per locale). RIGHT.

Ruling premise re-checked at the pin, both halves hold on my reading too: StringTagsWidget sets tags to [] for any non-array and add/remove write next built from tags back through onChange (widgets.tsx:1290-1310); sourceObjectName is interfaceConfig.source || data.object || object || objectName on the draft (ResourceEditPage.tsx:889-893) and ObjectSchemaBase (object.zod.ts:1593, wrapped unchanged by ObjectSchema at :2847) declares none of the four. Renderer facts the rows rely on: json is in KNOWN_PASSTHROUGH_WIDGETS (SchemaForm.tsx:229-236) and not in WIDGETS (widgets.tsx:2909-2929, which does register string-tags); resolveFieldFace (:667-750) with a passthrough hint derives nested-form for an object node and the scalar list for an array-of-string branch; resolveUnionBranch (:250-296) picks the branch by the stored value's type and falls to the first branch on create; setField (:1044-1050) spreads the stored object and writes or deletes one key. RIGHT.

Residue 28 → 23: derived from the inputs, not run. The analysis table's object-rooted unexplained set at 4e0f72e8d was 40 (object 15, field 15, action 7, app 1, page 1, permission 1) against 14 root ledger rows; the ledger at this head carries 26 root omit rows, the 12 added being field.format plus the eleven keys the ruling put in its three classes (six object, two field, one action, one app, one page). 40 − 12 = 28 with the per-type split object 9 · field 12 · action 6 · permission 1, which is the dev's base reading exactly; minus these five rows gives 23 (object 5, permission 0). Consistent; no probe file is in the diff. RIGHT.

② Semver level

Changeset .changeset/20349-object-permission-form-rows.md: @objectstack/spec: minor, @objectstack/platform-objects: patch, body first line Clause-②: no. The path arm is hit (non-test packages/spec/src/**), which is why this review is owed; the declared arm no is right because no schema's accepted input moves and no export changes. minor for spec is right for a new served authoring face and matches the precedent 408ca2e36 (#19673: spec: minor, platform-objects: patch, Clause-②: no, the same file family); patch for the regenerated bundles is right; nothing else that publishes is touched, so skip-changeset would be wrong and is not used. The changeset prose is accurate on the point the brief names: "publishing refuses it (object-field-ref-unknown, searchable-field-unknown, both at error), and so does os validate. The object schema's own parse does not judge these names." — verified: ObjectSchemaBase carries no refinement over the two lists; the refusal lives in @objectstack/lint, reached by the runtime publish gate (runtime-publish surface) and by os validate (runAuthoringRules('validate', …), cli/commands/validate.ts:384, the suite entry's commands: ALL). The PR body's Acceptance notes say the same. Body and changeset carry no model identifier; help texts cite ADRs only. Clause-②: no — RIGHT.

③ Boundary flags

Dev report open_questions: [] — nothing to answer there. Deviations, each answered:

  • Worktree from the remote branch with no empty-branch push — procedural, by the dispatch's order; no effect on the diff.
  • Residue probe in a second scratch worktree at 15bf186f5 with the two form files restored from 353e708a — the head's packages/spec/src differs from the base by exactly those two files (the other seven are bundles, tests and the changeset), so the after-tree is the head for that count; the reading reproduces by the derivation in ①.
  • check:dual-build-cjs-loads first exit 3 then 0 once dist/ existed — a prerequisite, not a finding; Build Core and Lint & Repo Gates are green on the head.

Out-of-scope findings, each answered:

Card acceptance met in full: one row per key mirroring a same-shape row, four-locale catalogue rows in the same PR generated as 408ca2e36 generated them, the residue drops by exactly the five, Clause-②: no. Draft PR, Fixes #20349, Clause-②: no in the body.

Implemented-by: claude/issue-20349-g1a-object-permission-rows
Reviewed-by: session_01ARcDurZ5j34RdqsGgc4jgH

VERDICT: PASS


Generated by Claude Code

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 protocol:data size/m tests tooling

Projects

None yet

2 participants