Skip to content

feat(spec,platform-objects): action param 'visible' predicate; gate create-user phone on features.phoneNumber - #2871

Merged
os-zhuang merged 1 commit into
mainfrom
fix/action-param-visible-phone
Jul 13, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
fix/action-param-visible-phone

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 13, 2026 •

Copy link
Copy Markdown
Contributor

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)"} — because phoneNumber is an opt-in better-auth plugin (off by default), yet the sys_user create_user action 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 optional visible predicate (ExpressionInputSchema / CEL), evaluated against the same scope as action-level visible (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 its phoneNumber param on features.phoneNumber == true. features.phoneNumber is already served in /api/v1/auth/config (getPublicConfig), so the client can evaluate it.

Back-compat: visible is optional; params without it are unaffected.

Verified

  • ActionParamSchema.safeParse({...visible}) parses + preserves the predicate; a param without visible still parses.
  • Paired objectui change (ActionParamDialog honoring param.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 — ActionParamDialog evaluates param.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

…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>
@vercel

vercel Bot commented Jul 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 13, 2026 3:33am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:ui tooling size/s labels Jul 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

96 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/cli.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/platform-objects, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectos/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/objectos/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/objectos/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/objectos/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/objectos/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via packages/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via packages/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/platform-objects, @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@os-zhuang
os-zhuang merged commit 609cb13 into main Jul 13, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the fix/action-param-visible-phone branch July 13, 2026 04:38
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

1 active deployment
Preview — c987fa02 Deployed Jul 13, 2026 by vercel[bot]
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 size/s tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant