Repository navigation
feat(spec,platform-objects): action param 'visible' predicate; gate create-user phone on features.phoneNumber - #2871
Merged
Conversation
…reate-user phone on features.phoneNumber ActionParamSchema gains an optional 'visible' (ExpressionInputSchema / CEL), evaluated against the same scope as action 'visible'. The sys_user create_user action's phoneNumber param is gated on features.phoneNumber == true, so the form only offers a phone field when the opt-in phoneNumber auth plugin is loaded — otherwise admin/create-user rejects the phone. Pairs with objectui's ActionParamDialog honoring param.visible (objectstack-ai/objectui#<pr>). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 96 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Jul 13, 2026
os-zhuang
pushed a commit
that referenced
this pull request
Jul 15, 2026
…params (#2874) Also lists the param-level visible predicate (added in #2871) in the ActionParam field summary, which had drifted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx
os-zhuang
added a commit
that referenced
this pull request
Jul 15, 2026
#2874) (#2965) * feat(spec,auth): add public auth feature-flag classification registry + drift guard (#2874 P0) Single registry (PUBLIC_AUTH_FEATURES) classifying all 13 boolean flags served by getPublicConfig() at /auth/config: consumption surface (crud/login/status), default semantics (opt-in '== true' vs default-on '!= false'), and gated spec inputs or an exemption reason. Includes featureGatePredicate + lowerRequiresFeature helpers for the upcoming requiresFeature sugar (P1). The plugin-auth drift guard derives the ACTUAL served key set from a real AuthManager and asserts it equals the registry keys, so a new flag that ships unclassified turns CI red. A semantics check also asserts each flag's registered default matches what getPublicConfig() actually serves - it already caught oidcProvider being default-ON (follows the MCP surface), which issue #2874's own table misclassified as opt-in. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx * feat(spec): declarative requiresFeature sugar on actions/params, lowered to visible CEL (#2874 P1) ActionSchema and ActionParamSchema gain an optional requiresFeature: <flag> (z.enum over the registry keys, so a typo fails the parse). A .transform() appended after the schemas' refinements lowers it at parse time into the canonical visible predicate - features.X == true for opt-in flags, features.X != false for default-on (per PUBLIC_AUTH_FEATURES semantics) - AND-composing with any explicit visible ((existing) && gate) and stripping the sugar key from the output, mirroring the normalizeVisibleWhen pattern (ADR-0089). A non-CEL or AST-only visible rejects loudly (ADR-0078) instead of silently dropping the gate. Renderers, lint, and objectui only ever see the canonical visible envelope - the objectui rendering chain needs zero changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx * refactor(platform-objects): migrate all 22 hand-written features.* gates to requiresFeature (#2874 P2a) Mechanical migration of every visible: 'features.X ...' predicate in the identity objects to the declarative requiresFeature annotation, including the compound transfer_ownership gate (residual row predicate stays hand-written; the lowering AND-composes the feature gate onto it). A behavior-equivalence matrix in platform-objects.test.ts pins each lowered visible.source to the exact CEL string that was previously hand-written (parenthesized-composition for the compound row), and asserts the sugar key never survives into parsed objects - so the migration is provably behavior-neutral. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx * feat(spec,platform-objects): gate the audited admin/twoFactor/oidcProvider surfaces + bidirectional completeness guard (#2874 P2b) Audit fixes - capability-dependent actions that previously rendered with the backing plugin off and then 404'd: - requiresFeature: 'admin' on the six sys_user platform-admin actions (ban/unban/unlock/set_user_password/set_user_role/impersonate_user); the stale 'still render but server returns 404' comment is updated. SCIM deployments keep them (SCIM forces features.admin on, ADR-0071). - requiresFeature: 'twoFactor' on the three sys_two_factor actions and the three sys_user self-service 2FA actions (composed onto their existing record.id == ctx.user.id predicates). - requiresFeature: 'oidcProvider' on the five sys_oauth_application actions (composed onto the enable/disable record predicates). feature-gate-guard.test.ts asserts registry<->objects lockstep in both directions: every gatedInputs path resolves to a real input carrying the flag's lowered predicate (removing an annotation goes red), and every features.* reference in a visible predicate is booked in the registry (adding a gate without bookkeeping goes red). Login-surface audit gaps are recorded in the registry and tracked in objectui#2513 (DeviceAuthPage unguarded) and objectui#2514 (passkeys/magicLink advertised but unconsumed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx * chore: changeset for the feature-gate registry + requiresFeature work (#2874) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx * docs(protocol): document requiresFeature capability gates on actions/params (#2874) Also lists the param-level visible predicate (added in #2871) in the ActionParam field summary, which had drifted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx * fix(ci): classify action/requiresFeature in the liveness ledger; avoid reserved word in docs example - Spec property liveness ratchet: requiresFeature is live at parse time (lowered into the already-live visible predicate and stripped), with evidence pointing at lowerRequiresFeature and the #2874 guard tests. - check-role-word (ADR-0090 D3): swap the docs composition example from transfer_ownership to disable_oauth_application so no new use of the reserved word ships. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx * fix(ci): commit api-surface snapshot (9 intentional additions) + regenerated action reference doc - api-surface: the #2874 registry exports (PUBLIC_AUTH_FEATURES et al.) are intentional public API - snapshot updated via gen:api-surface. - references/ui/action.mdx: regenerated. ActionParamSchema now ends in a .transform() (the requiresFeature lowering), which z.toJSONSchema cannot represent on the output side, so the generator drops the ActionParam block - the same pre-existing behavior as ActionSchema and ObjectSchema. The authoritative ActionParam field documentation lives in the hand-written protocol doc (content/docs/protocol/objectui/actions.mdx), updated in this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HGofzwNXvtahMGk8TQqXdx --------- Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Oct 7, 2026
…y notes state each decision in words instead of a tracker number (stage 7) (objectstack-ai#21636) Part of objectstack-ai#20749 Clause-②: no Stage 7 of the `domain:spec` lane's share of the runtime-string burn-down (ruling `5902360492`, A / A; form D per `5749154545`): class (f), the internal registry rationales in `packages/spec/src`. Every rewritten reason or note now states in words what the cited decision was, or drops a citation its sentence already explained. Text only. The card stays open for its later stages (`migrations/registry.ts`, then the test strings). ## What changed - **17 messages / 33 tracker ids / 22 distinct cited records** (21 cards and one PR) in two files: - `api/error-code-ledger.zod.ts`: the `reason` of all five `STANDARD_SYNONYM_WAIVERS` entries and of nine `PROVENANCE_WAIVERS` entries (14 messages / 29 ids). - `kernel/public-auth-features.ts`: `PUBLIC_AUTH_FEATURES.deviceAuthorization.exempt.reason`, `.phoneNumber.notes` and `.phoneNumberOtp.exempt.reason` (3 / 4). - One `@objectstack/spec` **patch** changeset, `Clause-②: no`. These are exported constants, and the new text ships in the package's `dist`: measured in 14 `.js` and 14 `.mjs` chunks, and in no `.d.ts` (the reasons are `+` chains, so their type is `string`). - No generated artifact moves (`check:generated`: all 15 up to date). No reader matches these strings by their text (see "Readers" below), so no test or gate moves. - Landing site: `packages/spec`, as the claim predicted. Each producer is the constant itself, so no other package is touched. ## Census at the base (A1) The earlier stages' `census.cjs` is not on disk in this container. I re-implemented it from stage 3's stated semantics, then held it to two references: - **The gate's own per-literal leg.** `check-doc-authoring.mjs --census` was run from a scratch copy whose `PACKAGES_PROSE_ROOT` / `PACKAGES_PROSE_EXCLUDED` were pointed at `packages/spec/src` and at nothing (two lines differ, nothing else). Result: 3 files, 231 = 231 ids, 0 per-file differences. - **Stage 6's numbers** at `2f30de851c`. Test strings 1804 / 1920 in 425 files, identical. Class (f) 17 / 33, identical, line for line against stage 3's table. At base `045b946256`, 1849 files were scanned and 1346 parsed after the byte prefilter, with 0 parse diagnostics: | file (under `packages/spec/src`) | messages / ids | message by message (`line:id`) | |:--|--:|:--| | `api/error-code-ledger.zod.ts` | 14 / 29 | 1618:8111 1619:8211 · 1625:13353 1627:8211 · 1633:8211 · 1639:19441 1640:8211 · 1646:8211 1647:8211 · 1752:11142,11230 · 1760:9415,9446 · 1766:9415,9446 · 1773:10025,11504,10413 1774:10576 · 1782:13353 1786:8016 · 1793:7870 1796:13353 · 1812:8016,3918 · 1821:8016,16322 · 1838:7780 1839:20492 | | `kernel/public-auth-features.ts` | 3 / 4 | 197:2513,2874 (the `2513` is the cross-repo `objectui#2513`) · 220:2871 · 230:2780 | The claim's raw-grep figures (192 and 14 lines) include comments. Comments are out of this stage. **Controls.** - Lit, multi-line: `error-code-ledger.zod.ts:1773-1776` reads as ONE message, with 4 ids on lines 1773-1774. - Lit, single: `public-auth-features.ts:220` reads as one message, `objectstack-ai#2871`. - Dark: `error-code-ledger.zod.ts:1589` (docblock, `objectstack-ai#8211`), `:1687` (a `//` heading, `objectstack-ai#13353`), `public-auth-features.ts:106` (`// [objectstack-ai#11544]`) and `:209` (a `//` inside the array, `objectstack-ai#9968`) each read 0. - Whole tree: 2151 hit lines, none on a comment line. The rest of the non-test population at this base is `migrations/registry.ts`: 45 messages / 198 ids. Stage 6 read 43 / 196 at `2f30de851c`, so main has since added two messages there. That file is the next stage's. ## Delivered (A2): each site, the decision as read, the new words I read each cited card in full through REST, body and every comment. The decision column names the record. Line is the base line of the message. | file:line | ids | decision as read | old → new | |:--|:--|:--|:--| | `error-code-ledger.zod.ts:1618` (`CONFLICT`) | 8111, 8211 | 8111: dev report `5272495731` (census-first route `5271501976`). The record-sharing errors moved onto the ADR-0112 envelope, and the unregistered 409 `CONFLICT` was registered as it stood under `@objectstack/rest`, so the wire stayed byte-identical. 8211: triage adjudication `5273232415`, option C. The admission gate refuses a semantic synonym unless waived. The existing synonyms are grandfathered by waivers, and consolidation (B) is deferred until a specific code has a measured victim | `(respondSharingError 409 arm; registered by objectstack-ai#8111). Wire value kept; consolidation deferred per objectstack-ai#8211.` → `(respondSharingError 409 arm), registered as it stood when the record-sharing errors moved onto the ADR-0112 envelope, so the wire stayed byte-identical. Wire value kept; consolidation deferred until it has a measured victim.` | | `error-code-ledger.zod.ts:1625` (`FORBIDDEN`) | 13353, 8211 | 13353: dev report `5489077644` and ACCEPT `5489099444`. The provenance gate landed, and cloud-connection's own-route 403 got its `FORBIDDEN` row; this waiver's reason was extended, not endorsed. 8211 as above | `objectstack-ai#13353 added the cloud-connection provenance row for the same pre-existing wire value` → `cloud-connection lists the same pre-existing wire value under its own provenance row`; the closing clause as for `CONFLICT` | | `error-code-ledger.zod.ts:1633` (`INTERNAL`) | 8211 | as above | `consolidation deferred per objectstack-ai#8211.` → `consolidation deferred until it has a measured victim.` | | `error-code-ledger.zod.ts:1639` (`NOT_FOUND`) | 19441, 8211 | 19441: dev report `5788584892`. plugin-security's class-field stamps got their rows, the overlay-discard 404's `NOT_FOUND` among them, and this waiver's reason named the new emitter. 8211 as above | `objectstack-ai#19441 added the plugin-security provenance row for the same pre-existing wire value` → `plugin-security lists the same pre-existing wire value under its own provenance row`; the closing clause as for `CONFLICT` | | `error-code-ledger.zod.ts:1646` (`UNAUTHORIZED`) | 8211 ×2 | as above. The card named four synonyms, and the detector found this fifth when it landed | `surfaced by the detector when the objectstack-ai#8211 gate landed, beyond the four the card named` → `surfaced by the synonym detector when it landed, beyond the four first reported`; the closing clause as for `CONFLICT` | | `error-code-ledger.zod.ts:1752` (`UPDATE_ID_MISMATCH`) | 11142, 11230 | maintainer rulings `5383569192` (unequal scalar `where.id`) and `5386672884` (non-scalar `where.id`). Both shapes are refused 400 with one code, from metadata-core's shared constructor, thrown by `ObjectQL.update`. The objectql row's comment records it | `records "hence registered here" (objectstack-ai#11142/objectstack-ai#11230).` → `records "hence registered here".` (the sentence already said it) | | `error-code-ledger.zod.ts:1760` (`FLOW_DISABLED`) | 9415, 9446 | 9415: card body and delivery `5322602563`. A disabled flow answers 409 `FLOW_DISABLED`, registered under runtime because the door names the wire vocabulary. 9446: maintainer ruling `5322867819`. The status table is a property of the flow-dispatch contract, so every door converges | `answer 409 (objectstack-ai#9415/objectstack-ai#9446; the runtime row's comment records the decision).` → `answer 409 — every door that dispatches a flow answers from one status table, by ruling (the runtime row's comment records the decision).` | | `error-code-ledger.zod.ts:1766` (`FLOW_NO_START_NODE`) | 9415, 9446 | as above, the 422 row | `Same decision as FLOW_DISABLED, 422 arm (objectstack-ai#9415/objectstack-ai#9446):` → `Same decision as FLOW_DISABLED, its 422 arm:` | | `error-code-ledger.zod.ts:1773` (`FLOW_INPUT_SCHEMA_INVALID`) | 10025, 11504, 10413, 10576 | 10025: maintainer ruling `5353928368` (B). A definition-level input-schema refusal is non-retryable and becomes a never-dispatched exit with its own code. 11504 (the card answers 404; its comments are still served, claim `5429880388`, dev report `5430346333`): the contract half of that ruling, which minted the code ahead of its producer. 10413 / 10576: maintainer ruling `5364977280` filed the contract half as its own card, ahead of the implementing half. That is the split shape | `Registered ahead of its producer by design (objectstack-ai#10025 → objectstack-ai#11504, the objectstack-ai#10413 → objectstack-ai#10576 split shape):` → `Registered ahead of its producer by design: the ruling that a definition-level input-schema refusal is non-retryable and never dispatched split into a contract half, which minted this code, and a services half that emits it, the contract half landing first.` | | `error-code-ledger.zod.ts:1782` (`EXTERNAL_IMPORT_ERROR`) | 13353, 8016 | 13353: as above. This entry was the first adjudication: a door case, so a waiver, not a row. 8016: dispatch ruling `5267576256` and dev report `5268577504`. One thrown-error mapping (`resolveThrownHttpError`, `@objectstack/types`) shared by both doors honours a throw's own `status` and `code`. `importNameRefusedError` sets exactly those two (`external-datasource-service.ts:337-342`) | `Adjudicated on objectstack-ai#13353:` → `Adjudicated when the provenance gate landed:`; `is the objectstack-ai#8016 declaration shape,` → ``declares its own `status` and `code`, the shape the shared thrown-error resolver honours,`` | | `error-code-ledger.zod.ts:1793` (`UPLOAD_SESSION_EXPIRED`) | 7870, 13353 | 7870: promotion `5270541620` and dev report `5274803860`. `resumeUpload` short-circuits on `expired` with the server's 410 pair. 13353: ACCEPT `5489099444`. The scope question stays recorded on the waiver, with no new card, for want of pull | `Client-side synthesis (objectstack-ai#7870):` → `Client-side synthesis:`; `is the open scope question objectstack-ai#13353 recorded —` → `is an open scope question, recorded when the provenance gate landed and left unruled for want of pull —` | | `error-code-ledger.zod.ts:1812` (`VALIDATION_FAILED`) | 8016, 3918 | 3918: card body. The dispatcher's exits answer a validation failure 400 with its `fields[]`, never 500, matching rest. 8016: `validationFailure` moved into `@objectstack/types` beside the shared mapping (`validation-failure.ts` header) | ``Shared constructor by design (objectstack-ai#8016/objectstack-ai#3918): `validationFailure()` lives in the dependency-light package so BOTH doors recognise one shape;`` → ``Shared constructor by design: `validationFailure()` lives in the dependency-light package beside the one thrown-error mapping both doors share, so BOTH doors recognise one shape and answer it 400 with its `fields[]`, never 500;`` | | `error-code-ledger.zod.ts:1821` (`ANALYTICS_DATE_RANGE_UNRECOGNIZED`) | 8016, 16322 | 16322: card body (the driver half of the date-range contract, director ruling batch 57, option A). Memory and SQL analytics drivers refuse an unrecognised `dateRange` identically, held by one shared conformance fixture. 8016: the shared-constructor shape, which the sentence already states | `Shared constructor one package over, the objectstack-ai#8016 shape (objectstack-ai#16322):` → `Shared constructor one package over:`; `refuse identically — which is the property the card's shared conformance fixture` → ``refuse an unrecognised `dateRange` identically — which is the property the shared date-range conformance fixture`` | | `error-code-ledger.zod.ts:1838` (`TENANT_SCOPE_REQUIRED`) | 7780, 20492 | 7780: maintainer ruling `5261730657`. An uninstall across every tenant must be declared explicitly; no organization and no flag is a loud 400, never every tenant's rows. 20492: triage `5877470267` and dev report `5878755305`. Every refusal runs before `uninstallPackage`, so a refused request changes nothing | `refuses with (objectstack-ai#7780), so a refused uninstall changes nothing (objectstack-ai#20492).` → `refuses a scope-less uninstall with (an uninstall across every organization must be declared, never inferred from a missing one), so a refused uninstall changes nothing.` | | `public-auth-features.ts:197` (`deviceAuthorization`) | 2513 (objectui), 2874 | objectui 2513: card body, closed completed 2026-07-15. DeviceAuthPage reads `features.deviceAuthorization` and shows a not-enabled state instead of calling the endpoints; the client type gained the key. 2874: card body. P2② is the login-surface audit of objectui's consumption | `Known gap: objectui DeviceAuthPage hits the device-auth endpoints without checking this flag (absent from its client type) — tracked in objectui#2513 (objectstack-ai#2874 P2②).` → `Login consumption verified: objectui DeviceAuthPage reads this flag and, when it is off, says device authorization is not enabled instead of calling the device-auth endpoints.` (see Acceptance notes: the old text was stale) | | `public-auth-features.ts:220` (`phoneNumber`) | 2871 | 2871 (a PR): body. The create-user form follows the plugin: the `phoneNumber` param is visible only on `features.phoneNumber == true`, and default-loading the plugin was rejected | `The original objectstack-ai#2871 fix.` → `The fix this registry generalizes: create-user's phone field follows the opt-in phoneNumber plugin instead of offering a field the backend refuses.` | | `public-auth-features.ts:230` (`phoneNumberOtp`) | 2780 | 2780: card body. SMS provider and messaging `sms` channel for phone OTP. `isPhoneOtpDeliverable()` (`auth-manager.ts:7735-7740`) answers false without an SMS service, and false for a log-only one in production | `when SMS is actually deliverable (objectstack-ai#2780).` → `when an SMS service can actually deliver the code; a log-only transport in production keeps it off.` | Six of the longer reasons (`FLOW_INPUT_SCHEMA_INVALID`, `EXTERNAL_IMPORT_ERROR`, `UPLOAD_SESSION_EXPIRED`, `VALIDATION_FAILED`, `ANALYTICS_DATE_RANGE_UNRECOGNIZED`, `TENANT_SCOPE_REQUIRED`) were re-wrapped after the edit so no continuation line is ragged. A script compared each message's joined string value before and after the re-wrap: identical, read back from disk. Code spans never break across a line. ## Readers (A3) - **Programmatic readers check non-emptiness only.** - `StandardSynonymWaiverSchema` / `ProvenanceWaiverSchema` require `reason: z.string().min(1)`. `error-code-ledger.test.ts` and `check:error-code-provenance` parse every waiver through them. - `public-auth-features.test.ts:54` requires `exempt.reason.length > 0`. - Control: all of them ran green on the head. 45 of 45 in the two spec files. `check:error-code-provenance`: "OK — every registered-code stamp site is listed under its own owner key or carries a recorded waiver (10 waiver(s), all live)", 330 sites, 311 listed, 19 waived. - **Nothing reads the text.** - Every 32-character window of the 17 old messages that the new text no longer contains (1274 windows) was searched with `git grep -F` across the tree, the two edited files excluded. One hit: a `//` section heading in `packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts:221`, not an assertion. - No `content/docs/**` page, `skills/**` file, snapshot or generated artifact quotes a changed message. - The `platform-objects` and `plugin-auth` registry guards read keys, `semantics` and `gatedInputs`, never `notes` or `reason`. ## Text only (A4) The earlier stages' `skeleton.cjs` is not on disk either, so I re-implemented it from its stated semantics, with TypeScript 6.0.3: - **Leg 1** is an AST skeleton. Every string's text is masked, and a `+` chain's adjacent string operands read as one string. Identifiers, numbers, regex literals, keywords and punctuation are kept, and comments are never read. - **Leg 2** is the text of every string group. Each changed group must carry a tracker id before and none after; every other group must be byte-identical. Base copies against the committed head `151a8ce4ed`: | file | skeleton tokens | string groups | changed | verdict | |:--|--:|--:|--:|:--| | `error-code-ledger.zod.ts` | 3184 | 429 | 14 | SAME, exit 0 | | `public-auth-features.ts` | 1639 | 112 | 3 | SAME, exit 0 | Parse diagnostics were 0 / 0. The 17 changed groups are the 17 census messages. **Controls**, on scratch copies of the head ledger. Each mutation was counted on disk: anchor hits 1, replacement present 1, anchor left 0, differs from head. | mutation | expected | got | |:--|:--|:--| | `standardSynonymViolations` renamed | DIFF | DIFF, exit 1 | | `===` flipped to `!==` in the waiver check | DIFF | DIFF, exit 1 | | one literal re-split into two `+` operands | SAME | SAME, exit 0, still 14 changed | | text changed in a `.describe()` string that never carried an id | VIOLATION | VIOLATION, exit 3 | | a rewritten reason given a tracker id back | VIOLATION | VIOLATION, exit 3 | No repo file was mutated for the controls. Edits were applied by a script whose 20 anchors each had to hit exactly once in the original text before any write. They were read back from disk after it: 20 gone, 20 replacements present once. **Census after:** class (f) **0 / 0**. Non-test went from 62 / 231 to 45 / 198, all of it `migrations/registry.ts`. That file is the lit control: the instrument still sees its ids, so the zero is not blindness. Test strings are unchanged at 1804 / 1920. The gate's per-literal leg agrees: 198 ids, all in `migrations/registry.ts`. ## Gates All heavy runs went through `scripts/pm/os-verify-lock.sh` (slot `dev-20749-s7`). Every exit code was written to a file before it was read. All runs are at `151a8ce4ed`. - `pnpm --filter @objectstack/spec build`: exit 0, "38/38 declared declaration file(s) present". - `pnpm --filter @objectstack/spec check:generated`: exit 0, "All 15 generated artifacts are up to date". - `pnpm --filter @objectstack/spec test`: "Test Files 608 passed (608) / Tests 18017 passed | 1 todo (18018)". - `pnpm --filter @objectstack/spec run typecheck`: exit 0, "check:test-typecheck: OK … 52 file(s) / 246 error(s) / 135 pinned signature(s) held". - The `repo` project's one file that reads a changed module (`scripts/file-description.test.ts`, which reads the ledger file's opening docblock): 111 of 111. - `pnpm turbo run build --concurrency=2` over every package: 71 / 71. This fed the dist-reading gates. On the first pass, before this build, three of them answered PREREQUISITE NOT MET (exit 3): `check:doc-formula-expressions`, `check:dual-build-cjs-loads` and `check:lean-entry-closure`. The other two (`check:dts-closure`, `check:sourcemap-no-sources-content`) had swept only 1 built package. All five were re-run after the build. - **Derived union.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived 82 commands for 3 paths vs merge base `045b94625`: 139 changed lines (+85 / -54), under 5000. All 82 exit 0 on the built tree. `--ran`: "82 derived famil(ies) accounted for — 82 run, 0 NOT-MEASURED (a DERIVED zero — all 82 recorded an exit code and none of them is 3)". - `check:doc-authoring`: "17351 customer-facing string(s) across 1255 spec sources clean" / "0 pinned site(s) … no growth, no burn-down unrecorded". - `check:nul-bytes`: "OK (scanned 9998 text file(s) …)". A self-scan of the added lines for raw control bytes found none. - `check:issue-citations`, `check:api-surface` and `check:error-code-provenance` all exit 0. - **NOT MEASURED** here, reason: the derivation names them as taking a value from the workflow (`check-issue-citations --census`, the shard attestations, `check-test-completeness`). They are CI's. - **ESLint, a proven narrowing.** `eslint --no-inline-config --format json` over the 2 changed TS files read 2 files, 0 errors, 0 warnings. Population comes from ESLint's own config: `calculateConfigForFile` returns a config for each, and `isPathIgnored` is false. Invariance: no type-aware linting (`parserOptions.project` / `projectService` are null for both), so a string-text edit cannot move an untouched file's verdict. Repo-wide `pnpm lint` is CI's. - The PR-body gates (`check-changeset-no-major --event`, `check-partof-closing-keyword`) were run against this exact title and body before it was sent; their lines are in the dev report. ## Acceptance notes - **One reason was stale, and the rewrite states the delivered decision.** The `deviceAuthorization` exemption said objectui's `DeviceAuthPage` called the device-auth endpoints without checking the flag, a gap "tracked in" objectui 2513. That card closed completed on 2026-07-15. At the `.objectui-sha` pin `89cad75d55`, `apps/console/src/pages/auth/DeviceAuthPage.tsx:66-130` reads `features.deviceAuthorization` and renders "Device authorization not enabled" when it is off, and `packages/auth/src/types.ts:286` declares the key. Stating that card's decision in words makes the text say the gap is closed. The registry's semantics, surface and keys are untouched. - **A cited card answers 404.** Card 11504 returns 404 from the issues endpoint. Its comments are still served (claim, dev report, ACCEPT), and its decision was read from them; it did not read as unclear. - **Main moved two commits after the base** (`a4f0cb0a45`, `0fc80878f8`: service-automation and lint, plus their changesets). Neither touches this PR's three paths or `packages/spec`, so main was not merged; the merge queue rebuilds onto current `main`. No open PR touches these paths: 12 open PRs read, file lists included. - Left for later stages, untouched here: `migrations/registry.ts` (45 / 198 at this base), the test strings (1804 / 1920), the two `.mjs` gate scripts, and every comment, including the docblocks and the `//` headings beside these tables. - Not governed: no `.claude/**`, `skills/**`, ADR, NORTH-STAR or AGENTS.md path. ## Line budget 139 changed lines (+85 / -54) over 3 files vs merge base `045b94625` (dispatch-gates): the ledger (+59 / -48), the auth registry (+9 / -6), and the changeset (+17). No generated file, no governed surface. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ Co-authored-by: Claude <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The create-user form offers a Phone Number field that the default backend rejects —
{"code":"invalid_request","message":"Phone numbers require the phoneNumber auth plugin (auth.plugins.phoneNumber)"}— becausephoneNumberis an opt-in better-auth plugin (off by default), yet thesys_usercreate_useraction statically advertises the param. This makes the form follow the plugin: no phone field unless the plugin is loaded.How
ActionParamSchema(spec/ui/action.zod.ts) gains an optionalvisiblepredicate (ExpressionInputSchema/ CEL), evaluated against the same scope as action-levelvisible(current_user/app/data/features). A dialog that honors it omits the param when it's false.sys_user.create_user(platform-objects/identity/sys-user.object.ts) gates itsphoneNumberparam onfeatures.phoneNumber == true.features.phoneNumberis already served in/api/v1/auth/config(getPublicConfig), so the client can evaluate it.Back-compat:
visibleis optional; params without it are unaffected.Verified
ActionParamSchema.safeParse({...visible})parses + preserves the predicate; a param withoutvisiblestill parses.ActionParamDialoghonoringparam.visible) is unit-tested (6 tests: feature-off hides, feature-on shows, absent-flag hides, malformed fails open, and the normalized{dialect,source}served form) with a browser regression showing the create-user dialog renders cleanly.Pairs with
objectui objectstack-ai/objectui#2406 —
ActionParamDialogevaluatesparam.visible. Both must land for the phone field to hide (spec adds the field; objectui honors it).Alternative considered — default-loading the phoneNumber plugin — was rejected: it turns an opt-in auth/sign-in surface (with an SMS-provider dependency for OTP) on for every deployment. "Form follows plugin" is the conservative fix.
🤖 Generated with Claude Code