Repository navigation
fix(console): a create form asks its fields the create question, and eighteen write affordances read one affordance-to-grant map (objectui#12082) - #12084
Conversation
…create question, through one affordance-to-grant map (objectui#12082) A create form gated its fields on checkField(..., 'write'), whose fallback for a field the permission set does not mention is allowEdit, so a create-only role met a create form with every field disabled and a save that posted an empty body. - core: AFFORDANCE_GRANTS + resolveAffordance / resolveFieldAffordance / formFieldsAffordance, the one map every affordance reads. - permissions: checkField accepts 'create' (explicit entry, else allowCreate), the server's insert rule. - plugin-form: every layout's field gate, outbound filter and form-wide lock read the form's row in the map. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
…affordance-to-grant map (objectui#12082) - RecordDetailView: resolveRecordHeaderActionGates takes the caller's permissions and resolves the recordEdit / recordDelete rows, so Edit and Delete read the update / delete grant (before: none at all). - ObjectView / ObjectDataPage: New and Import are the listNew / listImport rows (policy, effective operations and the create grant in one verdict, predicates only when it allows). - importTargetFields: the wizard's write targets ask the create question, so a create-only caller offered Import keeps its insertable fields. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
… grant (objectui#12082) The built-in quick-create read whether the field offers it and whether a host can carry it out, and no grant. It now reads the lookupCreateNew row of the affordance-to-grant map, asked of the referenced object (policy, effective operations and the create grant), so a caller who cannot create the target is not offered "Create new" (objectui#12081 item 6). Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
… the affordance-to-grant map (objectui#12082) RelatedRecordActionsBridge resolves the child's relatedNew / relatedRowEdit / relatedRowDelete rows and RecordAttachmentsPanel the attachmentUpload / attachmentDelete rows, instead of composing policy, operation set and grant each on its own. Same verdicts as before. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
… affordance-to-grant map (objectui#12082) - ListView: bulk Delete and the inline-edit toggle are the listBulkDelete / listInlineEdit rows. - ObjectGrid: row Edit / Delete, in-place editing and the add-record row are the rowEdit / rowDelete / listInlineEdit / gridAddRow rows, resolved against one source; resolveRowCrudAffordances now takes the map's verdicts instead of composing bucket, operations and grant itself. - ImportWizard: the template download is the importTemplate row. The add-record row now also reads the object's managed-object policy and effective create operation (the gridAddRow row), not only the grant. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
…e affordance-to-grant map (objectui#12082) - DetailView: its object-level Edit / Delete gate is the recordEdit / recordDelete row (operation set and grant); the object's own policy stays the host's channel, as objectui#4419 ruled. - record:details: in-place editing is the recordEdit row, so it now also reads the caller's update grant (before: bucket and operation set only), the same row the console header's Edit reads. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
…ap; a create-mode master-detail grid asks the create question (objectui#12082) - affordanceGrantMap-12082.test.tsx: the map's rows held to a hand-written expectation table; every row x four grant shapes (create-only, edit-only, read-only, full) through the real MePermissionsProvider; the field questions; the form reader; fail-open; and a census of console sources refusing a CRUD grant read outside the map. - MasterDetailForm: a create form's line cells ask the create question of the child (every line is an insert); edit forms keep the edit question. - console ProfilePage: the locale field reads the editFormFields row. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…he create question (objectui#12082) Document AFFORDANCE_GRANTS / resolveAffordance / resolveFieldAffordance in the core README, checkField's 'create' action in the permissions README, and the create-form field question in the plugin-form guide; the changeset now names every package whose source moved. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…nt-mask.mjs (objectui#12082) The enumeration pin's census stripped comments with a private regex, which check-hand-rolled-comment-mask refuses (a regex opens phantom comments on a glob or a URL and reports clean over code it never read). It now uses the shared stripComments, typed locally as the other package tests do. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: Inputs read: card #12082 (body and its four comments — triage ① Derived judgmentsGate verdicts on the head, as the check-runs answer them: Type Check, Lint, Test (shards 1–8 and dist pins), Build & E2E, Build Docs, Bundle Analysis, Changeset Declaration / Bump Policy / Claim Re-read / Fixed Group Check / Overwrite Report, README Export Check, Pre-Install Import Graph Check, Docs Route Eager Closure Check, Governed Surface Queue Guard, Line Citation Gate, Inert vi.mock Specifier Check, Control Byte Scan, Shell Escape Residue Scan, the four doc checks, Internal Docs Link Check, Skill checks, Spec Main Shape Gate, Action Ref Convention, Live E2E (informational), label — every one The accept-set and public-surface changes the diff implies, each judged:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Part of #12082 (the seat's answer B,
6094765692: five write affordances that read no grant at all are this card's follow-up)Part of #12081 (items 1, 5 and 6)
Clause-②: yes
A create form gated its fields on the EDIT grant, so a role that may create but not edit could not fill the form it is allowed to submit. This branch puts ONE affordance-to-grant map in
@object-ui/core, read by the eighteen console write affordances it names (the five that read no grant at all are the card's follow-up, see Acceptance notes), and makes a create form ask the CREATE question, which follows the server's insert rule.Implemented by the dispatched os-dev agent for the
domain:ui#3seat, sessionhttps://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8. The dev wrote this body when the draft opened. The seat brought it to round 2 from the dev's report6094735932and the claim amendment6094765692on objectui#12082: the reader list, the pins' readings and the acceptance notes below are round 2.Measured on
mainfirstmain1b2d0160f.packages/plugin-form/src/createFormGrant-12082.test.tsxmounts everyObjectFormlayout underMePermissionsProviderwith the card's grant (allowCreate: true, allowEdit: false, no field entries). Onmain: every field of the create form drawn locked on all nine layouts, and the create body on the three one-step containers was{}(24 failed, 9 passed). With this branch: 33 passed. The empty body is the card's400 VALIDATION_FAILED: the outbound filter asked the samewritequestion as the render gate.main76bc1e03, read in source).plugin-security's middleware step "2.5. Field-Level Security write enforcement" runs oninsertandupdatealike throughcomputeForbiddenFieldWrites→FieldMasker.detectForbiddenWrites, which refuses only a field ingetNonEditableFields— a field whose explicit entry is noteditable.PermissionEvaluator.getFieldPermissionsbuilds entries only for fields a permission set names, so a field with no entry passes the field step. Object admission mapsinsert→allowCreate(OPERATION_TO_PERMISSION). So a create-mode field reads: explicit entry → itseditable; no entry → the object's create grant. That is exactly whatcheckField(object, field, 'create')now answers; no client rule the server lacks is added.mainthe header Edit is the object's resolvededitaffordance (managed-object policy ∧ effective API operations) ∧ the explain engine's record verdict ∧ theuserActions.editpredicate. For hotclm'sclm_payment_plan(sharingModel: 'controlled_by_parent', nomanagedBy, nouserActions, noapiMethods) the only principal-dependent input is the explain record verdict, whichuseRecordEditablereads asrecord.visible. fix(plugin-security): explain's update verdict on a controlled_by_parent record comes from the master-detail write check objectstack#22529 (the pull request for security(explain):POST /api/v1/security/explainanswers allowed for an update of a controlled_by_parent record whose own PATCH refuses — explain asks sharing's canEdit, which reads controlled_by_parent as org-shared objectstack#22514, merged 2026-10-09, after the 17.7.0 tag — not an ancestor of the tag commit4e4e8814, control leg: the tag's 30th ancestor answers exit 0) made explain'supdateverdict on acontrolled_by_parentrecord come from the master-detail write check; its own reproduction table shows the hotclm shape (allowed: true,record.visible: false,PATCH200). plugin-security: the record-grained explain verdict forupdateis not computed with the write path's inputs —record.visibleis false on rows the by-id PATCH admits, so every consumer hides Edit from permitted users objectstack#19963 is the older card on the same shape. Nothing on objectui's side hid Edit from that caller; the map'srecordEditrow now also reads the update grant, and the pin holds that a caller withallowEditandupdatein the operation set gets Edit.allowCreate: false): does not reproduce as stated. Both list surfaces (ObjectView,ObjectDataPage) already ANDedcan(object, 'create')onmainand in 17.7.0. hotclm binds every position toclm_requesteras well as to its own set (src/security/bind-position-sets.ts), andclm_requestergrantsallowCreate: trueonclm_contract, so the records manager's EFFECTIVE grant allows create: the server would accept the create, and New / Import were right to show. The rows are pinned anyway, now read through the map.mainby source.LookupField'scanCreateread no grant at all. It now reads thelookupCreateNewrow, asked of the target object.The map
packages/core/src/utils/affordanceGrants.ts:AFFORDANCE_GRANTSnames each affordance's CRUD-affordance bit, the object grant it exercises and, for an affordance that offers fields, the field question (create= insert rule,write= update rule).resolveAffordanceis the one verdict — managed-object policy ∧ the server's effective API operation set (getObjectApiOperations, kept inside the map) ∧ the caller's grant — with the row'suserActionspredicates surfaced only when all three allow it.resolveFieldAffordanceasks a field the row's question.Why
core, notpermissions. Every reader already depends on both packages, andresolveEffectiveCrudAffordanceslives incore.permissionsdepends on@object-ui/typesalone; homing the map there would add apermissions → coreedge. Incorethe principal is a structural interface (can,getObjectApiOperations,checkField,isLoaded), socoregains no edge either.Fail-open stays. With no permission provider mounted,
usePermissions()answerscanwithtrueandisLoadedwithfalse: every grant reads open and no field question is asked, as thefieldWriteGate.tsdocblock states.Readers
Rows and readers (the census of
resolveEffectiveCrudAffordances,isObjectInlineEditable, CRUD-verbcan(/check(, andcheckField(…, 'write')readers) — each becomes a map reader or is named below as outside the family:ObjectFormlayout throughfieldWriteGate.ts(render pass, outbound filter,closedFormAffordance);RecordDetailViewresolveRecordHeaderActionGates;ObjectView,ObjectDataPage; the import wizard's writable target fields ask the create question (importTargetFields);LookupField, on the target object;MasterDetailFormline cells ask the create question of the child object, since every line there is a new record;RelatedRecordActionsBridge(related lists),RecordAttachmentsPanel(Upload / delete onsys_attachment),plugin-listListView(bulk Delete, inline-edit toggle),plugin-gridObjectGrid+rowCrudAffordances(row Edit / Delete, in-place editing, the add-record row;rowCrudAffordancesnow takes the map's verdicts) andImportWizard(template download),plugin-detailDetailView(operation set + grant; the object's own policy stays the host's channel per objectui#4419) andrecord:details(in-place editing reads the update grant), and the consoleProfilePage(the language field).Out of the family, with the reason:
managedByEmptyState(empty-state copy from the managed-object bucket; it shows or hides nothing);useFieldPermissionscanWrite/writableFields(the resolver's own API); the read gates (checkField(…, 'read')).Behaviour moves beyond the card's rows, all toward the server's refusal: the grid's add-record row now also honours the managed-object policy and the effective
createoperation;DetailView's object gate adds the effective operation set;record:detailsin-place editing reads the update grant; the form-wide lock and its notice also engage on a denied grant for the form's mode.Pins
editable: falsestays disabled and out of the body) —createFormGrant-12082.test.tsx;MePermissionsProvider, the row set held to the test's own expectation table, and a census that refuses a console source file reading a CRUD grant outside the map;editshare (sys_record_share) sees no Edit button while PATCH on the same record succeeds #10107, forms: when the object's create affordance is closed, every field is disabled with no explanation, and the wizard's Next stays enabled #11000, console: the record Attachments panel offers Upload and delete to a caller whose grant cannot attach or delete, so the upload fails at the last step #12047.Readings (dev report
6094735932):main1b2d0160ffirst: the card's pin 24 failed / 9 passed (every field locked, create body{}); the item-6 pin againstmain'sLookupField3 failed / 2 passed (controls green).createFormGrant-1208235/35,affordanceGrantMap-1208227/27,LookupField.createGrant-120825/5; the family pins' final run 161/161.scripts/ablation-replace.mjs, predictions first, every restore blob-equal): A1 the create form's field question back to'write'⇒ 19 red; A3 a grant read planted outside the map inLookupField⇒ the census names that file; A4recordEdit's grant swapped ⇒ 4 red; A5MasterDetailFormforced to edit mode ⇒ 1 red.check:eager-closurepasses at the head.Acceptance notes
6094765692): five write affordances read no grant at all, so the grep census over grant reads could not see them: plugin-viewObjectView's create button, plugin-calendar's quick-create and drag-to-reschedule, plugin-kanban's card move, and plugin-formLineItemsPanel's add / remove lines. The census pin refuses grant reads outside the map; it does not catch an affordance that reads no grant, and the follow-up says so or closes it.@objectstackpin includes that merge; item 5's New / Import are correct for that user's effective grant.MePermissionsProvider.checkkeysobjectsby the name as given, whilecheckFieldandgetObjectApiOperationslowercase it first. Every reader passes the object's own (lowercase) name, so nothing diverges today; noted, not changed.LookupFieldreadsallow_create ?? allowCreate(two spellings of one key) — outside this card, noted.Generated by Claude Code