Skip to content

Refactor: Separate authentication configuration from identity data models - #55

Merged
hotlong merged 6 commits into
mainfrom
copilot/refactor-authentication-architecture
Jan 21, 2026
Merged

hotlong merged 6 commits into
mainfrom
copilot/refactor-authentication-architecture

Conversation

Copilot AI commented Jan 21, 2026 •

Copy link
Copy Markdown
Contributor

Resolves architectural confusion between authentication providers/strategies (configuration) and user identity models (runtime data).

Changes

Migrated enterprise auth to auth.zod.ts

  • Moved OIDCConfigSchema, SAMLConfigSchema, LDAPConfigSchema from identity.zod.ts
  • Added enterprise field to AuthConfigSchema:
    enterprise: {
      oidc?: { issuer, clientId, clientSecret, ... },
      saml?: { entryPoint, cert, issuer, ... },
      ldap?: { url, bindDn, searchBase, ... }
    }

Redefined identity.zod.ts as data models

Removed authentication configuration schemas. Replaced with runtime data models:

  • UserSchema: Core identity (id, email, emailVerified, name, image, timestamps) - minimal and identity-focused
  • AccountSchema: Links external OAuth/OIDC/SAML providers to users
  • SessionSchema: Session state with device fingerprinting
  • VerificationTokenSchema: Email verification and password reset tokens

Created auth-protocol.ts

Wire protocol constants and interfaces:

  • AUTH_CONSTANTS: Standard headers, prefixes, cookies (Authorization, Bearer , os_*)
  • AuthHeaders, AuthResponse, AuthError, TokenPayload interfaces
  • AUTH_ERROR_CODES: Standard error codes

Added database field mapping for driver compatibility

  • Created DatabaseMappingSchema to map ObjectStack standard field names (Auth.js conventions) to driver-specific field names
  • Added mapping field to AuthConfigSchema with pre-configured better-auth defaults
  • Exported BETTER_AUTH_FIELD_MAPPINGS constant for maintainability
  • Default mappings bridge the gap between ObjectStack and better-auth:
    • sessionToken → token
    • expires → expiresAt
    • providerAccountId → accountId
    • provider → providerId
  • Fully customizable for any authentication driver (Auth.js, Passport, custom implementations)

Updated exports and documentation

  • Added enterprise SSO examples to AUTHENTICATION_STANDARD.md
  • Added database field mapping configuration section with examples
  • Updated index exports to reflect new 3-file structure

Architecture

Before: identity.zod.ts (mixed concerns)
├── AuthProviderSchema (config + data)
├── OIDC/SAML/LDAP configs
└── [no user data models]

After: Clean separation
├── auth.zod.ts → Configuration (how to login + field mappings)
├── identity.zod.ts → Data models (who is logged in)  
└── auth-protocol.ts → Wire protocol (how to communicate)

Driver Agnostic Design

The spec now supports any authentication driver through field mapping:

  • Default mappings for better-auth compatibility (via BETTER_AUTH_FIELD_MAPPINGS)
  • Customizable mappings for other drivers
  • Clean separation between spec fields (Auth.js conventions) and driver-specific fields

Breaking Changes

AuthProvider schema removed. Consumers should use:

  • AuthConfig with optional enterprise and mapping fields for configuration
  • User, Account, Session schemas for runtime data
  • AUTH_CONSTANTS for protocol constants
Original prompt

This section details on the original issue you should resolve

<issue_title>Please perform a final refactoring on PR #46 to consolidate the Authentication Architecture and resolve conflicts with the existing identity.zod.ts.</issue_title>
<issue_description>The goal is to strictly separate "Authentication" (Providers/Strategies) from "Identity" (User Models).

Task 1: Migrate Authentication Providers (Resolve Conflicts)

Migrate the legacy Enterprise Auth definitions from packages/spec/src/system/identity.zod.ts into the new packages/spec/src/system/auth.zod.ts.

  • Move OIDCConfigSchema to auth.zod.ts.
  • Move SAMLConfigSchema to auth.zod.ts.
  • Move LDAPConfigSchema to auth.zod.ts.
  • Add a new optional field enterprise to the main AuthConfig schema in auth.zod.ts to hold these configurations (e.g., enterprise: z.object({ saml: ..., ldap: ... }).optional()).

Task 2: Redefine Identity as User Model

Refactor packages/spec/src/system/identity.zod.ts to become the dedicated User Model Specification.

  • Remove AuthProviderSchema and all auth-related configs from this file (since they are now in auth.zod.ts).
  • Create and export UserSchema:
    • Fields: id (string), email (email), emailVerified (boolean), name (optional string), image (optional url), createdAt (date), updatedAt (date).
  • Create and export AccountSchema (for linking OAuth accounts).
  • Create and export SessionSchema.

Task 3: Define Wire Protocol & Constants

Create packages/spec/src/system/auth-protocol.ts (new file) to define the API contract:

  • Export AUTH_CONSTANTS:
    • HEADER_KEY: 'Authorization'
    • TOKEN_PREFIX: 'Bearer '
    • COOKIE_PREFIX: 'os_'
    • CSRF_HEADER: 'x-os-csrf-token'
  • Export AuthHeaders interface.

Task 4: Cleanup & Consistency

  • Ensure packages/spec/src/index.ts exports the new schemas from both auth.zod.ts (Configuration) and identity.zod.ts (Data Models).
  • Update docs/AUTHENTICATION_STANDARD.md to reflect that Enterprise Authentication (SAML/LDAP) is now part of this unified standard.

This refactoring will result in:

  1. auth.zod.ts: How to login (Config for OAuth, Email, SAML, better-auth driver settings).
  2. identity.zod.ts: Who is logged in (Standard User/Session data models).
  3. auth-protocol.ts: How to communicate (API constants).</issue_description>

Comments on the Issue (you are @copilot in this section)


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

@vercel

vercel Bot commented Jan 21, 2026 •

Copy link
Copy Markdown

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

Project Deployment Review Updated (UTC)
spec Error Error Jan 21, 2026 10:58am

Request Review

…ity models

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…ion)

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown
Contributor

This PR is very large. Consider breaking it into smaller PRs for easier review.

Copilot AI changed the title [WIP] Refactor authentication architecture and resolve identity conflicts Refactor: Separate authentication configuration from identity data models Jan 21, 2026
Copilot AI requested a review from hotlong January 21, 2026 09:26
@hotlong

hotlong commented Jan 21, 2026

Copy link
Copy Markdown
Contributor

@copilot 这是一段可以直接发给 AI (Copilot) 的提示词。这段提示词明确指出了模型字段与 better-auth 的差异,并要求通过添加“字段映射配置”来解决这个问题,使架构更加健壮。

您可以直接复制以下内容回复在 PR 的评论中,或者发给您的 AI 助手:


Please update packages/spec/src/system/auth.zod.ts to explicitly handle the schema differences between our ObjectStack standard (which follows Auth.js conventions) and the default better-auth models.

While the separation of concerns is great, our AccountSchema and SessionSchema use different field names than better-auth (e.g., sessionToken vs token, providerAccountId vs accountId).

Action Items:

  1. Update AuthConfigSchema in auth.zod.ts:
    Add a databaseMapping section (distinct from the existing database adapter config) that allows defining column names. This will serve as the instruction set for the Driver to map our Spec fields to the underlying engine's fields.

    Suggested structure to add inside AuthConfigSchema:

    /**
     * Database field mapping configuration.
     * Useful when the underlying driver (e.g., better-auth) uses different column names
     * than the ObjectStack standard schemas.
     */
    mapping: z.object({
      user: z.record(z.string()).optional(),
      session: z.record(z.string()).default({
        sessionToken: 'token',
        expires: 'expiresAt'
      }),
      account: z.record(z.string()).default({
        providerAccountId: 'accountId',
        provider: 'providerId',
      }),
    }).optional(),
  2. Verify UserSchema Compatibility:
    Ensure the UserSchema in identity.zod.ts remains minimal and strictly for "Identity" (profile data), while keeping sensitive auth data (like password hashes) handled internally by the driver or linked via the Account model, to avoid leaking implementation details into the general user model.

This ensures our Spec remains "Driver Agnostic" while providing built-in compatibility for our default driver (better-auth).

…ort)

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>

Copilot AI commented Jan 21, 2026

Copy link
Copy Markdown
Contributor Author

@copilot 这是一段可以直接发给 AI (Copilot) 的提示词。这段提示词明确指出了模型字段与 better-auth 的差异,并要求通过添加“字段映射配置”来解决这个...

I've implemented the database field mapping configuration to handle schema differences between ObjectStack (Auth.js conventions) and better-auth.

Changes made in commit fb0b264:

  1. Added DatabaseMappingSchema to auth.zod.ts:

    • User field mapping (optional)
    • Session field mapping with defaults: sessionToken → token, expires → expiresAt
    • Account field mapping with defaults: providerAccountId → accountId, provider → providerId
    • Verification token field mapping (optional)
  2. Added mapping field to AuthConfigSchema:

    • Optional configuration distinct from database adapter config
    • Pre-configured with better-auth compatible defaults
    • Fully customizable for other drivers
  3. Verified UserSchema compatibility:

    • Confirmed it's minimal and identity-focused
    • No password hashes or sensitive auth data
    • Only profile fields: id, email, emailVerified, name, image, timestamps
  4. Added comprehensive tests (7 new tests):

    • Default mapping behavior
    • Custom mapping overrides
    • better-auth compatibility validation
  5. Updated documentation:

    • Added "Database Field Mapping" section to AUTHENTICATION_STANDARD.md
    • Included examples and default mappings table
    • Explained when to use custom mappings

The spec is now truly driver-agnostic while providing built-in compatibility for better-auth.

…nability

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
@hotlong
hotlong marked this pull request as ready for review January 21, 2026 11:26
Copilot AI review requested due to automatic review settings January 21, 2026 11:26
@github-actions

Copy link
Copy Markdown
Contributor

This PR is very large. Consider breaking it into smaller PRs for easier review.

@hotlong
hotlong merged commit 69b5785 into main Jan 21, 2026
13 of 14 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request successfully refactors the authentication architecture by separating authentication configuration from identity data models, resolving architectural confusion and establishing clear boundaries.

Changes:

  • Migrated enterprise authentication configurations (OIDC, SAML, LDAP) from identity.zod.ts to auth.zod.ts under a new enterprise field
  • Redefined identity.zod.ts as pure data models (User, Account, Session, VerificationToken) representing "who is logged in"
  • Created auth-protocol.ts with wire protocol constants and interfaces (AUTH_CONSTANTS, AuthHeaders, AuthResponse, etc.)
  • Added database field mapping configuration to support driver compatibility (particularly better-auth)
  • Comprehensive test coverage for all new schemas
  • Extensive documentation updates with practical examples

Reviewed changes

Copilot reviewed 30 out of 30 changed files in this pull request and generated no comments.

Show a summary per file
File Description
packages/spec/src/system/identity.zod.ts Complete refactoring to define User, Account, Session, and VerificationToken data models; removed authentication configuration schemas
packages/spec/src/system/identity.test.ts New comprehensive test suite covering all identity data models with validation and type inference tests
packages/spec/src/system/auth.zod.ts Added enterprise auth configurations (OIDC, SAML, LDAP), database field mapping schema with better-auth defaults, and integrated into AuthConfig
packages/spec/src/system/auth.test.ts Added thorough tests for enterprise auth schemas and database mapping functionality
packages/spec/src/system/auth-protocol.ts New file defining wire protocol constants, interfaces for headers/responses/errors, and standard error codes
packages/spec/src/index.ts Updated exports with clear comments distinguishing configuration, data models, and wire protocol
packages/spec/json-schema/*.json Generated/updated JSON schemas for all new and modified types; removed deprecated AuthProvider and AuthProtocol schemas
docs/AUTHENTICATION_STANDARD.md Added enterprise SSO examples, database field mapping documentation, and updated architecture overview
content/docs/references/system/*.mdx Updated/created MDX documentation for all new schemas with property tables

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
… contained failures into the run-level `failed` (objectstack-ai#16314) (objectstack-ai#18261)

Fixes objectstack-ai#16314

Clause-②: no

Services half of objectstack-ai#15617's ruling (director seat, decision batch objectstack-ai#55,
maintainer 「同意」 on option 1, 2026-09-06). The spec half landed the slot
on `68d5dfd0f`; this populates it.

## What moved

`ExecutionStepMetrics.failures` is declared as *"node executions that
failed inside a child run this execution delegated to and went on
from"*, folding into `nodes[].failures` and so into the run-level
`failed`. Nothing wrote it, so the engine's fold could not see a child's
losses: a parent that delegated its rows reported `failed: 0` while
`acted` had rolled up since objectstack-ai#4354. Four producers, each read rather than
assumed:

| site | what it now reports |
| :--- | :--- |
| `builtin/subflow-node.ts` | a synchronous child's `summary.failed`, on
the **non-failed** exit only |
| `builtin/map-node.ts` | the accumulated `failed` of the items that
COMPLETED during this entry — `map` does **not** share `subflow`'s
roll-up path, so it needed its own |
| `engine.ts` · `creditChildRun` | a child that PAUSED, whose parent
step was written at suspend time; both call sites are completion paths,
which is what puts them inside the declared rule |
| `run-summary.ts` · `summarizeRun` | folds `metrics.failures` into
`nodes[].failures` and so into `failed` |

## The measured target, driven on the real engine

The card's shape from objectstack-ai#15617 — parent `loop { subflow(child) }`, one
child failing per five rows:

```
before   status=completed selected=5 acted=4 skipped=0 failed=0
after    status=completed selected=5 acted=4 skipped=0 failed=1
children failed = [0, 0, 1, 0, 0]          (unchanged — the child keeps its own row)
```

**The control, unchanged and pinned.** A child that **failed** rather
than contained is the delegating step's own failure, counted once:
`call: {runs: 5, failures: 1}`, parent `failed = 1`, with nothing of the
child's own `failed` riding up. That is the one place the rule parts
from `acted`'s, which does carry a failed child's writes — asserted
beside it so neither direction can be made symmetric without a red test.

⚠️ One correction to the control's fixture, measured rather than
assumed: a `loop` body is fail-fast, so an **unguarded** call to a
failing child ends the loop at the third row (`call: {runs: 3}`) and the
loop node records a failure of its own beside the call's, giving `failed
= 2`. The control's declared numbers are the parent that CONTAINS — the
same shape the contained-child case uses, differing only in which level
contains. Both readings are in the test's own comments.

**A delegating node's `status` does not move.**
`FlowRunNodeSummary.status` is declared judged on the node's OWN
executions, so a `subflow` step that ran fine and rolled a child's
losses up reads `success` with `failures > 0`, and on such a node
`failures` may exceed `runs`. The fold takes the status verdict
**before** it adds the roll-up; that ordering is load-bearing and has
its own ablation leg below.

PR objectstack-ai#15609's narrowed wording — *"no node execution **of this run**
failed"* — was true only while the declaration's two paragraphs
disagreed. It is widened back here in `formatRunSummaryLine`'s comment
and in `content/docs/automation/flows.mdx`.

## ⚠️ Fence — objectstack-ai#18110 is NOT folded in, and the two are separable

objectstack-ai#18110 is a separate, still-ungraded card on the same file, in the
opposite direction: a **`refused`** child (the run-OUTCOME sense — an
`end` node saying no) rolled up as an ordinary success, the refusal
reaching nobody.

They are **not** mechanically inseparable, and the reason is mechanical
rather than a judgement call: `selected` / `acted` / `unmeasuredEffect`
already ride the exact exit a refused child takes today, and `failures`
was added to that **same** exit. So this diff adds one total to an
existing roll-up and decides nothing new about which child outcomes
reach it. Nothing here pins the refused case in either direction —
deliberately, so objectstack-ai#18110's fix stays free to decide what a parent does
with a refused child's totals, all four at once.

## Reverse verification — six ablation legs

Each leg mutates one COMMITTED source file, proves the mutation reached
disk by occurrence counts on the anchored text **plus** a blob-hash
inequality against `HEAD` (never an editor's exit code), runs the suite,
restores with `git checkout HEAD -- path` and proves restoration by blob
hash equality with `git diff HEAD` empty. A `trap` on EXIT/INT/TERM
restores every touched path by absolute path. Baseline and post-restore
runs are both 16/16 green.

| leg | mutation | result |
| :--- | :--- | :--- |
| A | the fold stops adding the roll-up | 9 failed / 7 passed |
| B | roll-up added **before** the status verdict | 6 failed / 10 passed
— incl. "reads `success` with `failures > 0`" and "`failures` may exceed
`runs`" |
| C | `subflow` stops rolling a completed child up | 2 failed / 14
passed — the measured target and the node status |
| D | the asymmetry removed: a FAILED child's `failed` rides up too | 2
failed / 14 passed — exactly the two control tests |
| E | `map` stops accumulating its completed items' failures | 1 failed
/ 15 passed — the `map` test |
| F | `creditChildRun` stops crediting a PAUSED child | 2 failed / 14
passed — both resume directions |

A first pass declared B/D/E VOID rather than green: its landing proof
used `grep -cF` on a multi-line needle, which splits into per-line
patterns and counts lines. The legs were re-run with a python-side
occurrence count, and the void reading is reported rather than quietly
replaced.

No permanent ablation artefact is left behind; the worktree is clean and
every restored blob matches `HEAD`.

## Verification

- `pnpm --filter @objectstack/service-automation test` — **136 files /
1615 tests passed**.
- `pnpm --filter @objectstack/service-automation typecheck` — clean
(`tsc --noEmit` + `check:test-typecheck`, 0 files / 0 errors in the
test-layer ledger).
- **Derived gate families: 92 derived / 92 run / 0 NOT MEASURED / 0
UNRUN.** Derived by `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` from the merge-base change set,
reconciled back with `--ran` carrying each command's own exit code
captured before any pipe: *"92 derived famil(ies) accounted for — 92
run, 0 NOT-MEASURED (a DERIVED zero — all 92 recorded an exit code and
none of them is 3)."* Three of them first answered **PREREQUISITE NOT
MET** (`check:skill-examples`, `check:dual-build-cjs-loads` exit 3,
`check:type-check-debt` exit 3) — ⛔ not passes; the workspace build
closure was built and all three then exited 0.
- `eslint . --no-inline-config` over the **whole** repo population —
**6764 files, 0 errors, 0 warnings**. Not a narrowed run, so no
narrowing needs declaring.
- Control-character self-scan over the diff's seven files: no match
(`check:nul-bytes` is also in the 92).
- Not measured here, declared to CI: the required contexts on the merge
group's affected set.

## Clause-②: no — derived, not predicted

Derived by **reachability from the published entry**, with controls, ⛔
never from the word `export` and ⛔ never from a grep in `dist/index.js`.
The package's `exports` declares one entry (`.` → `dist/index.js` /
`dist/index.d.ts`) and `files` ships `dist`; the named-export set of
that entry was read from the published type surface and cross-checked
against the barrel chain in `src/index.ts`.

- **Positive controls** — `summarizeRun`, `formatRunSummaryLine`,
`AutomationEngine`, `registerLogicNodes`, `installBuiltinNodes`:
REACHABLE, so the derivation can answer yes.
- **Negative controls** — `registerSubflowNode` and `registerMapNode`
are `export function` in their own modules and re-exported by
`src/builtin/index.ts`, yet the root barrel's explicit list names six
builtins and not those two ⇒ **unreachable by name**; `creditChildRun`
(a `private` member) and a nonexistent identifier: also unreachable. So
the derivation can answer no, and it is not a grep for `export`.
- The delivered diff adds **zero** export statements outside the test
file, and **zero** keys to any published payload — `metrics.failures`,
`nodes[].failures` and `failed` are all keys `packages/spec` already
declares.

⚠️ Declared honestly: what moves is the **value** on an
already-published payload — a delegating parent's `failed` changes from
"this run's own node failures" to "what this run caused". That is
conformance to a declaration already on `main`, not a widened surface or
a relaxed acceptance set.

## Acceptance notes

- ⛔ `packages/spec` untouched — the contract said everything needed,
including the failed-child boundary and the
`status`-judged-on-own-executions rule, both of which this implements
verbatim.
- The card's re-check grep names
`packages/services/service-automation/src/subflow-node.ts`; the file
lives at `src/builtin/subflow-node.ts`. Path drift only — the premise
holds. Noted, not filed.
- `loop { subflow(failing child) }` unguarded answers `failed = 2` for
one lost row: the loop node's own failed execution plus the subflow
step's. Literally correct under *"total node executions that failed"*,
and unchanged by this diff. Noted, not filed.
- The refused-child roll-up is objectstack-ai#18110's, left exactly as found. Noted,
not filed here.

Authored by Claude Code in session `session_01URLHobLUJB9K1ABV6ofdjj`.

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…psMap rows, derived from the renderers' read points (objectui#8348 Q1-C) (objectstack-ai#18403)

Fixes objectstack-ai#18305

Executes the objectui#8348 ruling 「8348 以协议为准」 (decision batch objectstack-ai#83,
2026-09-08) and batch objectstack-ai#136 item 3 (Q1-C, maintainer 「同意」):
`ComponentPropsMap` gains `object-map`, `object-gantt` and
`object-tree`, each row's key set derived the **objectstack-ai#7751 way — from the
objectui renderer's own read points**, at the `.objectui-sha` pin
`53ded82b`.

Not derived from `@object-ui/types`' mirror. That mirror standing in as
the authority for map and gantt is the defect this card closes, and for
`object-tree` the mirror is measurably wrong about four keys (below).

## The objectstack-ai#7751 derivation table, extended by three rows

Every citation is a line in the objectui checkout at `53ded82b`. The
shared record-source ladder is `resolveRecordSourceConfig` in
`core/src/utils/record-source.ts` — rung 1 `data` (`:151`, returned
VERBATIM as a `ViewData`), rung 2 `staticData` (`:155`, wrapped into `{
provider: 'value' }`), rung 3 `objectName` (`:162`).

### `object-map` — `plugin-map/src/ObjectMap.tsx`, registry shell
`plugin-map/src/index.tsx`

| key | read point | declared as |
|---|---|---|
| `objectName` | ladder rung 3; `:793` (fetch dep), `:890` (navigation)
| `z.string().optional()` |
| `data` | `:169` array-shorthand head, then `resolveRecordSourceConfig`
at `:174` | `ViewDataSchema` |
| `staticData` | ladder rung 2 | `z.array(z.unknown())` |
| `filter` | `:742` — `$filter: schema.filter` |
`z.array(ViewFilterRuleSchema)` |
| `sort` | `:743` — `$orderby: convertSortToQueryParams(schema.sort)` |
`z.array(SortItemSchema)` |
| `map` | `:370` — `getMapConfig` branch 1, the author face; the
registration's declared `{ name: 'map', type: 'object' }` input |
`z.unknown()` (see note) |
| `mapStyle` | `:365` — `schema.mapStyle \|\| schema.map?.style` |
`z.string()` |
| `navigation` | `:889` — `useNavigationOverlay` | `z.unknown()` |
| `enableClustering` | `:905` — `enableClustering ??
(schema.enableClustering \|\| …)` | `z.boolean()` |

Measured and deliberately NOT declared: the flat `map`-config spellings
`getMapConfig` branch 2 reads at `:382-396` (`locationField`,
`latitudeField`, `longitudeField`, `titleField`, `descriptionField`,
`zoom`, `center`) — the ObjectView/ListView flatten product, ruled an
internal transport form, not a second authoring surface (maintainer
ruling objectui#5018, 2026-08-17); `style`, which `:283` reads ONLY to
say it is not consumed as a map style (objectui#5017) and which is the
node's own inline CSS record; and the React props of `ObjectMapProps`
(`clusterRadius`, the `data` ARRAY prop, the four callbacks,
`className`, `dataSource`).

### `object-gantt` — `plugin-gantt/src/ObjectGantt.tsx`, registry shell
`plugin-gantt/src/index.tsx`

| key | read point | declared as |
|---|---|---|
| `objectName` | ladder rung 3; `:1359` (layout key), `:1491`
(navigation), `:1873` (export name) | `z.string().optional()` |
| `data` | `resolveRecordSourceConfig` at `:593` | `ViewDataSchema` |
| `staticData` | ladder rung 2 | `z.array(z.unknown())` |
| `filter` | `:738` — `$filter: schema.filter` |
`z.array(ViewFilterRuleSchema)` |
| `sort` | `:739` — `$orderby: convertSortToQueryParams(schema.sort)` |
`z.array(SortItemSchema)` |
| `gantt` | `:499-501` — `getGanttConfig` branch 1; the registration's
`{ name: 'gantt', type: 'object' }` input, and `:501` validates it
against this repo's OWN `GanttConfigSchema` | `GanttConfigSchema` |
| `navigation` | `:1487` — `schema.navigation ?? { mode: 'drawer' }` |
`z.unknown()` |
| `label` | `:1871` — `resolveInlineI18nLabel(schema.label,
displayLocale)` in the export-file-name chain (`:1849` is the comment
ABOVE that chain, not a read) | `I18nLabelSchema` |
| `skipWeekends` | `:1205` — `!!sw` into the working calendar |
`z.boolean()` |
| `holidays` | `:1206` — `new Set(hol)` for the duration math |
`z.array(z.string())` |
| `persistLayout` | `:1357` — `schema.persistLayout === false` |
`z.boolean()` |
| `viewName` | `:1359` — the `objectName:viewName` storage key |
`z.string()` |
| `markers` | `:1826` — `markers={schema.markers}` |
`z.array(z.unknown())` |
| `criticalPath` | `:1829` —
`criticalPathDefault={!!schema.criticalPath}` | `z.boolean()` |
| `showBaselines` | `:1832` — `schema.showBaselines !== false` |
`z.boolean()` |
| `readOnly` | `:1833`, `:1916` — `!!schema.readOnly` | `z.boolean()` |
| `mobileReadOnly` | `:1834` — `schema.mobileReadOnly !== false` |
`z.boolean()` |

Measured and deliberately NOT declared: the 30 flat `GanttConfig`
spellings branch 2 reads at `:513-543` (objectui#6469 inherited the
objectui#5018 ruling for this block); `title`, which this renderer never
reads; and a row cap — the reload's `$top` is the platform ceiling
`NON_GRID_ROW_CEILING_TOP`, which the renderer's own comment marks 「⛔
Not authorable」.

### `object-tree` — `plugin-tree/src/ObjectTree.tsx`, registry shell
`plugin-tree/src/index.tsx`

| key | read point | declared as |
|---|---|---|
| `objectName` | ladder rung 3; `:534`, `:570`, `:605` |
`z.string().optional()` |
| `data` | `resolveRecordSourceConfig` at `:359` — rung 1, returned
VERBATIM as a `ViewData`. That ONE site is the whole support for this
arm | `ViewDataSchema` |
| `staticData` | ladder rung 2 | `z.array(z.unknown())` |
| `filter` | `:474` — `$filter: schema.filter` |
`z.array(ViewFilterRuleSchema)` |
| `tree` | `:108` — the nested block `getTreeConfig` reads; the
registration's `{ name: 'tree', type: 'object' }` input |
`TreeConfigSchema` |
| `navigation` | `:591` — `useNavigationOverlay({ navigation: (schema as
any).navigation })` | `z.unknown()` |

**`data` IS on the row, and that is the measurement, not family
symmetry.** The card left this conditional — 「if the renderer's authored
surface has no `data`, the row declares none」 — and the condition is
FALSE here: the renderer reaches `schema.data` through the shared ladder
at `:359`, which returns it verbatim as a `ViewData`. ⛔ `:496` is
**not** a second site for that arm, and an earlier revision of this body
over-claimed it as one: `(rest as any).data ?? (schema as any).data` is
gated by `Array.isArray(passed)` on the very next line, so it honours
only the bare-ARRAY shorthand this row **refuses** — the same shorthand
`object-map` measures and declines above. One ladder site is sufficient
and `:359` is it, so the row stands on a corrected citation. What is
absent is the DECLARATION, on both published faces: `ObjectTreeSchema`
on `@object-ui/types` at this pin declares no `data`, no `staticData`,
no `filter` and no `navigation` at all, and makes `objectName` required
— four keys the renderer reads. That is precisely the mirror-is-wrong
shape the ruling puts the protocol in front of, so the row follows the
read points and the mirror is the face that has to follow.

**No `sort`.** `ObjectTree.tsx:473-484` issues `$filter`, `$top` and
`$expand` and no `$orderby`, and nothing else in the file reads an
order. A `sort` door here would publish a key with no read site — the
exact defect objectstack-ai#7751 exists to remove, in the other direction.

## Three derivation decisions, each pinned rather than left to review

1. **The flat config spellings stay unauthorable on all three.**
`ObjectView` / `ListView` build these nodes by spreading `options.map` /
`options.gantt` / `options.tree`'s CONTENTS at the top level, carrying
no block key at all; that is an internal transport form (objectui#5018,
inherited by objectui#6469), and the map and gantt renderers name every
flat key beside a present block as IGNORED in a dev warning. Writing one
now gets a wrong-layer prescription naming the config block — the
channel `object-calendar` already uses. Each set is held equal to the
config block it points at (`ListMapConfigSchema.shape`;
`GanttConfigSchema.shape` plus the legacy singular `dependencyField`;
`TreeConfigSchema.shape` plus `titleField`, which `getTreeConfig:117`
reads only as `labelField`'s last fallback), so it cannot drift
silently.
2. **`filter` and `sort` are the family's one orthography from birth** —
`ViewFilterRule[]` (ui#6206-B reaching the family: objectstack-ai#15449, batch objectstack-ai#55
option A) and `SortItem[]` (objectui#8221, batch objectstack-ai#77 option B). A new
door on a family-wide ruling has no "measured before the ruling" arm.
The whole-map census pins in `component.test.ts` already demanded it:
every `filter` door must refuse the record form, and only
`record:related_list` may take a string `sort`.
3. **Config-block VALUES follow the read point.** `gantt` takes
`GanttConfigSchema` because `ObjectGantt.tsx:501` literally validates
the authored block against that schema, imported from
`@objectstack/spec/ui`. `tree` takes `TreeConfigSchema`, which objectstack-ai#15469
shut against unknown keys on this very measurement, re-measured
unchanged here. `map` stays `z.unknown()` — and that is measured, not
conservative-by-default: the spec's own `ListMapConfigSchema` is strict
and declares no `style`, the key `getMapConfig:365` reads at
`schema.map?.style`, so pointing this door at it would refuse a value
the renderer honours today. The pin records the reason so the later
value ratchet has one.

## Measured file face

13 files, all additive to a published surface; nothing retired, nothing
narrowed.

```
.changeset/18305-component-props-map-map-gantt-tree.md      new
packages/spec/src/ui/component.zod.ts                       +3 schemas, +3 rows, +3 guidance sets
packages/spec/src/ui/component.test.ts                      +1 describe, 4 door lists extended
packages/spec/src/ui/filter-rule-array-guidance.test.ts     +3 doors (seven -> ten)
packages/spec/src/ui/component-type-vocabulary.test.ts      +1 pin
packages/spec/authorable-surface/ui.json                    +32 rows   (generated)
packages/spec/json-schema.manifest/ui.json                  +3 entries (generated)
packages/spec/api-surface/ui.json                           +9 exports (generated)
packages/spec/export-origins/ui.json                        generated
packages/spec/declaration-map/ui.json                       generated
content/docs/references/ui/component.mdx                    generated
content/docs/references/index.mdx                           generated
docs/audits/2026-07-unknown-key-strictness-ledger.counts.md generated (444 -> 447 sites)
```

No corpus moves: a whole-tree grep for an authored `object-map` /
`object-gantt` / `object-tree` page node finds zero outside
`sdui.manifest.json` (a registry dump, not authored metadata), against a
positive control that finds `object-grid` authored twice in
`examples/app-showcase`. `PageComponentSchema.type` already accepted all
three through its open string arm and still does; what changes is that
an authored props bag on one of them is judged instead of skipped.

## Verification

All readings below are at `e7967ce543` (the branch tip) unless a row
says otherwise, exit codes captured by redirect-then-`$?`, never through
a pipe. The spec suite, typecheck and the generated-artifact checks were
re-run after the patch round below.

| run | verdict |
|---|---|
| `pnpm --filter @objectstack/spec build` | exit 0 |
| `pnpm --filter @objectstack/spec test` | exit 0 — 482 files, 13727
tests passed |
| `pnpm --filter @objectstack/spec typecheck` | exit 0 (`tsc --noEmit` +
`check:scripts-typecheck` + `check:test-typecheck`) |
| `pnpm --filter @objectstack/lint test` | exit 0 — 103 files, 3821
passed, 5 skipped (the `ComponentPropsMap` consumer; its closure built
first) |
| `pnpm --filter @objectstack/lint typecheck` | exit 0 |
| `pnpm --filter @objectstack/spec check:generated` | exit 0 after
`--fix` regenerated the 5 it proved stale |
| `MANIFEST=… check:react-declaration-parity --baseline … --strict` |
exit 0 — `object-map` 2 both / 7 spec-only / **0 registry-only**,
`object-gantt` 2 both / 14 spec-only / **0 registry-only**; baseline
ratchet: no new divergence |
| `pnpm exec eslint . --no-inline-config --format json` | exit 0 —
**6786 files, 0 errors, 0 warnings**, repo-wide, no narrowing |
| 47 further derived gates (`scripts/pm/dispatch-gates.mjs --commands`)
| all exit 0 — see the acceptance notes for the one that did not |

`packages/spec` has no workspace dependencies, so the dependency-closure
build is an empty run by construction.

CI on this head is **not enumerated here** and is not claimed green —
the verdicts above are local runs only.

## Patch round — the at-tier review's two text findings, and the
citations

The contract review returned PASS; these are the three things its scope
did not settle, all of them text or citation, with **no key, no value
shape and no row membership moved**. Verified after the round rather
than asserted: the three key sets read back identical,
`ComponentPropsMap` still carries 48 rows, `object-tree` still accepts
the `ViewData` object arm and still refuses the bare array, the gantt
block is still strict, `object-chart` is still absent.

1. **`object-gantt`'s describes said "chart".** Every sibling names its
own noun, and `object-chart` is the row this section deliberately leaves
absent, so "chart" pointed an author at the one block with no row. Three
describes now name gantt (`objectName`, `label`, `readOnly`). **Proof on
the shipping artifact** — `content/docs/references/ui/component.mdx`,
regenerated with the repo's own `gen:docs`, never by hand: `Object this
chart binds to` → **0**; `Object this gantt binds to` → **1**, at
`:398`; control `Object this NOUN binds to` → **6**, unchanged, so all
six blocks name their own noun exactly once.
2. **`object-tree`'s `objectName` gave a reason that is false for
tree.** It said the component-level `dataSource` binding can supply the
object. Measured at the pin `53ded82b`: `plugin-tree/src/index.tsx` has
**0** hits for the `ElementDataSource*` wiring, against **7** each in
`plugin-map`, `plugin-gantt`, `plugin-grid` and `plugin-calendar` — four
controls, so the zero discriminates. The key stays optional; the reason
is now the true one, the ladder's first two rungs (`data` can name the
object itself, `staticData` needs none), plus the explicit negative. In
the generated doc the false reason reads **0** hits and the new line
**1**, at `:768`.
3. **Two citations corrected**, in the tables above: gantt `label` at
`:1871` (`:1849` is the comment), and tree `data` on `:359` alone.

`check:docs` was red on `e3a256daa3` — source fixed, shipping artifact
not yet regenerated — and is green on `e7967ce543`: `check:docs` exit 0,
"223 generated files in sync with packages/spec"; `check:generated` exit
0, "All 15 generated artifacts are up to date";
`check:authorable-surface` exit 0, which is why `gen:schema` had nothing
to write and `gen:docs` was the only stale artifact. `pnpm --filter
@objectstack/spec test` exit 0, 482 files / 13727 tests, and `typecheck`
exit 0, both re-run after the round. `git diff --name-only
origin/main...HEAD -- content/docs/releases/` → **0** paths, against a
control of **2** under `content/docs/references/`.

On this head `Type Check · source gates` is **completed / success** —
the job the red had truncated now reaches its end. The rest of CI is
still running and is not claimed green.

## Patch round 2 — the at-tier review's FAIL: F1 blocking, F2 and F3 in
passing

Review of record: comment `5696414075` on this PR, `VERDICT: FAIL` at
head `e7967ce543`. That review re-derived the accept set, the public
surface, all three key sets, every value posture and the `minor` level
and judged them RIGHT — nothing in this round moves a key, a value
shape, a row membership or the level. Text, plus the one regeneration it
forces.

**F1 (blocking) — six describes named a function that does not exist in
the code they describe.** The `objectName` / `data` / `staticData`
describes on `object-gantt` and `object-tree` said the record source is
resolved by, or "read FIRST/SECOND by", `getDataConfig`. Re-measured at
the `.objectui-sha` pin `53ded82b`, each file fetched with `git show` in
the same command block as its count:

| file at `53ded82b` | `getDataConfig` | `resolveRecordSourceConfig` |
|:---|---:|---:|
| `plugin-gantt/src/ObjectGantt.tsx` | **0** | 2 — import `:64`, call
`:593` |
| `plugin-tree/src/ObjectTree.tsx` | **0** | 3 — import `:41`, call
`:359`, comment `:422` |
| `plugin-map/src/ObjectMap.tsx` (control) | **8** | 3 |

The control discriminates, and it also settles what the map keeps:
`ObjectMap.tsx` really does hold a local `getDataConfig` at `:135` — the
array-shorthand head, delegating to the shared ladder at `:174` — so the
three map describes are TRUE and keep the name. Gantt and tree reach the
shared three-rung ladder directly: `resolveRecordSourceConfig` in
`@object-ui/core` `utils/record-source.ts:146`, rungs at `:151` (`data`,
returned verbatim), `:155` (`staticData`, wrapped as `{ provider:
'value', items }`) and `:162` (`objectName`, folded to `{ provider:
'object', object }`). So the ladder ORDER those six sentences describe
was already true and only the name was wrong; the six now name
`resolveRecordSourceConfig`. Neither renderer has any reader above its
ladder call, so "read FIRST" is literal for both — unlike the map, whose
array head runs first, which is the second reason its describes keep the
wrapper's name.

**Both sides counted, because this trap already bit this PR once.** An
earlier round fixed a false describe in the source alone and
`check:docs` caught the generated doc still carrying the old text. After
`gen:docs`, at `c431c22e05`:

| | `getDataConfig` | `resolveRecordSourceConfig` |
|:---|---:|---:|
| `packages/spec/src/ui/component.zod.ts` | **3** — map only, `:3226`
`:3242` `:3244` | 11 |
| `content/docs/references/ui/component.mdx` | **3** — map rows only,
`:638-640` | **6** — gantt `:398-400`, tree `:768-770` |

Both sides read 9 and 0 before the round. The generated diff is exactly
six table rows; no map row moved.

**F2** — `component.zod.ts:3545`, the tree `data` field JSDoc, cited two
read points where the schema header at `:3487` already rules that `:496`
is not a second site for the object arm. It cites `:359` alone now.
Source comment; no artifact follows it.

**F3** — two citations off by one line, both re-read at the pin:
`getGanttConfig`'s `safeParse` is `ObjectGantt.tsx:500` (`:501` is the
`if (!result.success)` beneath it), and the tree's "Not authorable"
marker is `ObjectTree.tsx:482`, not `:477`.

### Verification for this round

- `pnpm --filter @objectstack/spec build`, then `check:generated` — exit
0, all 15 artifacts up to date including `check:docs`, with
`content/docs/references/ui/component.mdx` actually regenerated by
`gen:docs` rather than hand-edited. Before the regeneration the same
gate named that one artifact, and only it, stale.
- `pnpm --filter @objectstack/spec test` — exit 0, 482 files / 13727
tests. `pnpm --filter @objectstack/spec typecheck` — exit 0.
- `check:objectui-pin-citations` — exit 0, 26 asserting pin citations
match `53ded82bf`. It verifies the sha, not the line numbers, and
reports 0 anchor content assertions here: F3 was measured by hand at the
pin, not caught by this gate.
- 34 of the 109 gate families `dispatch-gates` derives for this change
set were run locally, every one exit 0; the remaining 75 are the
repo-wide farm CI owns. A declared narrowing, not a silent skip.
- `pnpm lint` narrowed to the diff, with the three readings that make
the narrowing a measurement: the population is eslint's own
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and eslint itself answers the
regenerated `.mdx` with "File ignored because no matching configuration
was supplied", so the linted population of this diff is one file;
`--format json` reports 1 file, 0 errors, 0 warnings; and no type-aware
linting is configured anywhere in `eslint.config.mjs` (no
`projectService`, no `parserOptions.project`), so a comment-and-describe
edit in one file cannot move any untouched file's verdict.

## Acceptance notes

### Docs Drift Check, re-verified rather than waved through

The drift check flagged 6 hand-written pages. Each was re-read; none
needs an edit, and the readings are here rather than implied by their
absence from the diff.

- **`content/docs/protocol/objectui/layout-dsl.mdx` does not ride this
PR — measured, not assumed.** It is flagged via the symbol
`ComponentPropsMap`, which it names exactly once, at `:721`, and as a
MECHANISM: *"For the platform's own types the authoring rules dispatch
`ComponentPropsMap` and reject a misspelled prop; a `custom.*` type has
no entry there."* That sentence is still exactly true after this change
— three more types now have entries, and it enumerates none. The page
carries no register of the object-block family at all: no table, no
list, and its only numeric completeness claim is about breakpoints
(`:579`). **The discriminating control:** `object-calendar` and
`object-form` each get **0** hits on that page, and both have carried
`ComponentPropsMap` rows since objectstack-ai#7751 landed on 2026-08-12 — so the page
already omits 2 of the 6 pre-existing rows, against a positive control
of `object-metric` (3), `object-master-detail-form` (3), `object-kanban`
(2), `object-grid` (1). A page that has never tracked the row set is not
a list this change makes incomplete; its `### Metric Widget` / `###
Master-Detail Forms` / `### Kanban Board` sections are chosen worked
examples. Writing three new hand-written renderer sections would be new
documentation of three renderers, which is a card, not a rider on "add
three rows".
- **The other five pages are literal-name collisions, not semantic
links.** `content/docs/ui/views.mdx`, `concepts/architecture.mdx`,
`getting-started/common-patterns.mdx`,
`protocol/kernel/i18n-standard.mdx` and
`ui/field-grouping-and-order.mdx` each get **0** hits for
`ComponentPropsMap` and **0** for `object-map` / `object-gantt` /
`object-tree`. They are reached only through string literals that this
PR's new `OBJECT_GANTT_FLAT_CONFIG_GUIDANCE` key array happens to
contain — `groupByField`, `timeZone`, `colorField`, `startDateField` and
friends. `views.mdx` documents the LIST-VIEW `gantt` block (`type:
'gantt'` with `gantt: { startDateField, … }`, `:340-352`), whose schema
is `ListViewSchema.gantt` / `GanttConfigSchema` in `view.zod.ts` — a
file this diff does not touch at all, so that page's vocabulary is
unmoved.
- **Release-owned pages: untouched, with a control.** The drift check
names 7 pages under `content/docs/releases/`; they are read-only. `git
diff --name-only origin/main...HEAD -- content/docs/releases/` returns
**0** paths, against a control of **2** paths under
`content/docs/references/` in the same diff, so the zero is a reading
and not an empty filter.

### `component-reference-rail.test.ts` needs no change — the reading,
not the omission

The card names it among "the vocabulary tests that pin the row set".
Measured on this branch: the file is 164 lines, mentions
`ComponentPropsMap` 4 times, and **every one of them is the single
subscript `['record:reference_rail']` or prose about that one row**
(`:3`, `:21`, `:33`, `:35`). `Object.keys(ComponentPropsMap)` → **0**
hits; any `object-` type literal → **0** hits. Control, the same two
probes on `component-type-vocabulary.test.ts`, which genuinely does pin
the row set: **6** and **8** ⇒ the zeros discriminate. All twelve of its
`describe`/`it` subjects are `record:reference_rail` behaviour. It reads
no property of the row SET, so nothing in it can move when three
`object-*` rows land — and it passed unchanged inside the 482 green spec
files above.

The row-set pins that DO move with the rows are `component.test.ts` (six
ruled blocks to nine, plus the filter / sort / plural-`filters` /
optional-`objectName` door lists, plus a new describe) and
`component-type-vocabulary.test.ts`. The *reference rail* that
regenerates is the docs one, `content/docs/references/ui/component.mdx`.


### Out of scope for this PR, noted rather than fixed

Each with the measurement that found it:

- **`ListMapConfigSchema` declares no map style at all.** The spec's
list-view map block is strict and has neither `style` nor `mapStyle`,
while `ObjectMap.tsx:365` reads `schema.map?.style` and objectui's
`ObjectMapConfigSchema` declares `style`.
`ListMapConfigSchema.safeParse({ style: '…' })` is refused — pinned as a
negative in this PR's own tests, because it is the reason
`object-map.map` stays `z.unknown()`. An author cannot declare a map
style through the spec's list-view face today. Carrier: this PR's `map`
value ratchet, whenever it is taken.
- **`object-tree` is absent from the tracked `sdui.manifest.json`.**
`check:react-declaration-parity` prints `object-tree: NO component in
the manifest — not registered or not public`, although
`plugin-tree/src/index.tsx` registers it under both `object-tree` and
`tree`. The baseline ratchet is unaffected (a block with no baseline row
cannot regress), so this PR is green either way, but the new row gets no
parity comparison. Carrier: the next `sdui:manifest` regeneration.
- **`pnpm check:cross-package-test-inputs` is sensitive to local build
state, not to this diff.** It exits 1 in a worktree where
`packages/spec/dist/` exists and 0 where it does not — measured here as
a one-variable control, with the diff held constant and the directory
restored byte-identically (216 files, `dist/ui/index.mjs` hash
unchanged). It names
`packages/cli/test/init-created-files-summary.e2e.test.ts`, which
symlinks `packages/spec/dist` at `:115`; this PR touches neither
`packages/cli` nor `turbo.json`. The `Lint & Repo Gates` job runs it on
an unbuilt tree, which is why CI is green on it.
- **`objectui#9239` has moved since the card was written.** The card
describes it as an open, separate calendar-mirror contradiction; it is
`closed completed`. Nothing in this PR depends on it, and
`object-calendar` is untouched.
- **objectui's own gantt face carries the same false attribution F1
removed from ours, and it is pre-existing.** At the same pin `53ded82b`,
`packages/types/src/zod/objectql.zod.ts` docblock `:726` states
`getDataConfig` is in `plugin-gantt/src/ObjectGantt.tsx`, and three
`ObjectGanttSchema` describes repeat the name (`:738`, `:739`, `:838`) —
while that file has 0 occurrences of it (control: `ObjectMap.tsx` 8, and
the map's own mirror describes at `:641-643` are correspondingly TRUE).
Separately, `core/src/utils/record-source.ts` quotes our describes as
the ruled contract at `:28`, `:92`, `:95` and `:97` and attributes the
text to the map and gantt zod twins, so after this PR that quotation
matches the map twin and no longer matches the gantt one. Both sites are
objectui's, in objectui's repo; nothing here can fix them and the pin
does not move. Handed to the seat to file against objectui rather than
fixed in passing.

Not touched, deliberately: the `object-chart` deliberate-absence note in
`component.zod.ts`, pinned as still absent in the new tests.

objectui#8348 carries `pm:blocked` on this card; its remaining slice
judges map / gantt / tree against these rows once this lands. Nothing
here changes any objectui file.

Authored by the `domain:spec` execution seat, session
`session_01KB5PFtxuy1x3dcR5gxudx6`.

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…ction-*, data-* and element-* migration entries states each lesson in words, not tracker numbers (stage 4) (objectstack-ai#20509)

Part of objectstack-ai#20233
Stage 4: the datasource-, filter-, action-, data- and element- families.

Clause-②: no

**Stage 4 of a staged card.** The card stays open for later stages; this
PR carries no closing keyword. Text only: no entry id, `surface`, `from`
/ `to`, conversion or matching logic moves, and the chain rewrites
exactly what it rewrote before.

## What this does

`os migrate meta` prints every ADR-0087 semantic entry it crosses as one
block: `⚠ [protocol N] SURFACE → REPLACEMENT`, then `why:` (the entry's
`reason`) and `verify:` (its `acceptanceCriteria`). AGENTS.md's
runtime-string rule applies to all of it: 「Runtime strings — refusal
prose, prescriptions, anything an author is shown — carry no tracker
number (`pnpm check:doc-authoring`): the lesson goes into the text.」
Form **D** of ruling C+D on objectstack-ai#19123 (`5749154545`) sets the shape: the
lesson in words, and no number, dead or alive; a cross-repo number
(`cloud#N`, `objectui#N`) is still a tracker number.

This stage covers the next five families, `datasource-`, `filter-`,
`action-`, `data-` and `element-`: **150 sites → 0** in the three prose
fields, across 33 entry files. None of the 37 entries in these families
carries a tracker id in `surface` (ruling A of the stage-1 ACCEPT,
`5858839916`, is checked and has nothing to do here). Each site now says
what the cited ruling, measurement or fix decided. ADR ids stay.
`registry.ts`, `spec-changes.json` and `docs/protocol-upgrade-guide.md`
are regenerated from the entries (`gen:migration-registry`,
`gen:spec-changes`, `gen:upgrade-guide`), never hand-edited. The pin now
holds eleven families.

## Census — tracker ids in the author-shown fields

**Instrument.** The stage-2 AST instrument, re-written to its published
description (the stage-2 and stage-3 copies lived in removed
scratchpads): a TypeScript-AST walk over every
`packages/spec/src/migrations/entries/**/*.ts`. For each entry object
literal it evaluates the string value of `replacement`, `reason`,
`acceptanceCriteria` and (counted separately) `surface`, joining string
literals with `+`, then counts `#` followed by 4 or 5 digits at a word
boundary. **Validated first** by reproducing earlier readings on their
own trees (extracted with `git archive`): on `443b2f4fdc` (stage 1)
`engine-` 67, `datasource-` 43, `filter-` 30, `action-` 26, `element-`
25, `data-` 24, whole tree 266 entries / 1,016 sites / 9 `surface`; on
`0d7ed5a378` (stage 3 landed) whole tree **711** sites / 7 `surface` —
every figure equal to the stage-1 census and to stage 3's after-count.
Unevaluable fields: 0.

**Controls, same run.**
- **Lit:** `17.aggregation-node-distinct-retired.ts` reads 7 sites
(replacement 1, reason 6), before and after.
- **Dark (comment lines):** 823 `//` / docblock lines in entry files
carry a tracker id, and none is counted; 823 before and after. Comment
lines are the sibling card objectstack-ai#20234's surface, and this PR touches none
(proved below).
- **Dark (field boundary):** the 7 `surface` sites left in the tree
(other families) count 0 in the three-field total.

**Base `fc0db22b`:** `datasource-` 9 entries, **43** sites (3 / 38 / 2);
`filter-` 11, **32** (1 / 30 / 1); `action-` 6, **26** (0 / 23 / 3);
`element-` 5, **25** (2 / 19 / 4); `data-` 6, **24** (0 / 24 / 0).
**150** sites (6 / 134 / 10) in 33 of the 37 entries; 88 distinct ids
(80 bare, 8 `objectui#`, 2 `cloud#`). Whole tree: 310 entries, **721**
sites (711 after stage 3, plus entries `main` added since in other
families), 7 `surface`.

**After this PR:** all five families **0**; the six earlier families
still 0; whole tree **721 → 571**; `surface` 7 (other families). The
PM's rough line count (156 sites, 33 files) is a wider instrument; the
AST reading is 150 in the same 33 files.

| entry | sites (replacement / reason / acceptanceCriteria) | short
`#NN` |
|---|---|---|
| `17.action-descriptor-is-async-retired` | 2 (0 / 2 / 0) |  |
| `17.action-descriptor-resume-authority-default-flip` | 10 (0 / 7 / 3)
| |
| `17.action-session-roles-to-positions` | 12 (0 / 12 / 0) |  |
| `17.data-driver-find-stream-retired` | 1 (0 / 1 / 0) |  |
| `17.data-driver-query-omit-object` | 9 (0 / 9 / 0) |  |
| `17.data-engine-batch-retired` | 1 (0 / 1 / 0) |  |
| `17.data-field-changed-event-retired` | 4 (0 / 4 / 0) |  |
| `17.datasource-config-inline-credential-refused` | 1 (0 / 1 / 0) |  |
| `17.datasource-config-placeholder-refused` | 5 (0 / 5 / 0) |  |
| `17.datasource-config-url-userinfo-refused` | 4 (0 / 4 / 0) |  |
| `17.filter-regex-options-retired` | 8 (0 / 8 / 0) |  |
| `18.action-bulk-dispatch-contract-undeclared` | 1 (0 / 1 / 0) |  |
| `18.action-engine-facade-find-query-envelope` | 1 (0 / 1 / 0) | 1 |
| `18.data-file-value-duration-unit-in-key` | 6 (0 / 6 / 0) | 1 |
| `18.data-nosql-query-options-timeout-unit-in-key` | 3 (0 / 3 / 0) | 1
|
| `18.datasource-config-mongo-options-credential-refused` | 5 (0 / 5 /
0) | |
| `18.datasource-config-postgres-url-unparseable-refused` | 4 (0 / 4 /
0) | |
| `18.datasource-config-url-query-credential-refused` | 4 (0 / 4 / 0) |
|
| `18.datasource-credentialsref-mongo-composed-no-username-refused` | 10
(2 / 7 / 1) | |
| `18.datasource-credentialsref-mongo-url-no-user-refused` | 10 (1 / 8 /
1) | |
| `18.element-data-source-and-object-block-filter-rule-array` | 11 (1 /
9 / 1) | 1 |
| `18.element-number-filter-rule-array` | 7 (0 / 6 / 1) |  |
| `18.element-record-picker-filter-rule-array` | 7 (1 / 4 / 2) |  |
| `18.filter-between-blank-endpoint-refused` | 3 (0 / 3 / 0) | 1 |
| `18.filter-between-field-reference-endpoint-refused` | 5 (1 / 4 / 0) |
|
| `18.filter-comparand-types-and-widget-nested-slots-refused-at-save` |
2 (0 / 2 / 0) | |
| `18.filter-equality-array-comparand-refused` | 1 (0 / 1 / 0) |  |
| `18.filter-equality-array-comparand-refused-at-save` | 1 (0 / 1 / 0) |
|
| `18.filter-icontains-comparand-refused-at-parse` | 3 (0 / 3 / 0) |  |
| `18.filter-ne-array-comparand-refused` | 1 (0 / 1 / 0) |  |
| `18.filter-preset-ordering-comparand-refused` | 4 (0 / 4 / 0) |  |
| `18.filter-query-face-comparands-refused-at-save` | 1 (0 / 1 / 0) |  |
| `18.filter-text-operator-declared-type-refused` | 3 (0 / 2 / 1) | 1 |
| **total, 33 entries** | **150 (6 / 134 / 10)** | **6** |

## Text only — proved by a base-vs-head AST comparison

For every entry file this PR changes, both versions (`fc0db22b` and the
head) are parsed and three things are compared token for token: every
import declaration, every property other than the three prose fields (so
`id`, `surface`, `from` / `to` and any matcher, by evaluated value), and
every comment token in the file. **33 files compared, 0 with a non-prose
change.** The instrument is shown able to fail first: on an in-memory
copy it reports DETECTED for a mutated `id`, a mutated comment and a
mutated `surface`. So none of objectstack-ai#20234's comment lines moved, and no
entry's identity or matching moved.

## Every citation read, and what the text now says

Each cited id was read with a single-card REST read (body plus the
ruling, measurement or landing comments), resolved against the
repository its sentence names. Ids are in code spans so this body posts
no cross-references. **10 bare ids answer 404** on both the issues and
the pulls endpoint (re-probed with a 200 control, `14478`), and **2
`cloud#` ids are unreadable from this session** (403); those sentences
are rewritten from what `main` records, listed in Acceptance notes.

**`datasource-` (14 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `7990` | Maintainer, 2026-08-12, Option A: close inline credentials
per artefact (each schema refuses at publish and diverts to its existing
mechanism); the heuristic `sys_metadata` write guard parked. | "The
maintainer ruled on 2026-08-12 to close that per artefact: each schema
that admitted an inline credential refuses it at publish …"; "the
inline-credential closure" |
| `8078` (PR) | The spec half of that ruling; measured that `${…}`
placeholders reach the client verbatim and that `config.url` still took
the secret. | "measured by the credential census while the
inline-credential refusal was being built"; "measured when the
inline-credential refusal was built" |
| `8082` | Maintainer, 2026-08-12, option A: refuse URL userinfo at
publish through one shared value-level parse; runtime DSNs unaffected. |
"The maintainer ruled on 2026-08-12 (Option A) to refuse the URL
userinfo password at publish, through one value-level parse the driver
schemas share" |
| `8336` | Maintainer, 2026-08-13, direction 2 of two: refuse `${…}` at
publish; implementing resolution rejected. | "The maintainer ruled on
2026-08-13 for the second of two directions: refuse the syntax loudly at
publish" |
| `8337` | The credential-bearing URL query parameter refusal, one
syntax over from userinfo. | "the credential-bearing URL query
parameters"; "the query string was the third spelling of the identical
secret, one syntax over" |
| `8876` | **404** — see Acceptance notes. | "the asymmetry the URL
grammar keeps between its two userinfo halves — a username is not
credential material" |
| `8696` | **404** — see Acceptance notes. | "measured when the bound
secret was made to reach the mongo client on its URL branch"; "`new
URL()` rejects the multi-host form outright, and the mongo arm hands the
authored URL to its client untouched"; "the defect class closed when
each DSN branch was made to inject the bound secret its composed branch
already used" |
| `9041` | **404** — see Acceptance notes. | "the sibling URL-branch
entry, `datasource-credentialsref-mongo-url-no-user-refused`"; "this
URL-branch refusal" |
| `9147` | The composed-branch twin: bound secret + no `url` + no
`username` refused, inheriting the URL-branch ruling. | "this
composed-branch refusal" |
| `7314`, `7385`, `8152`, `8875` | The driver-factory arms that dropped
something declared: the turso loader's install remedy and half its
config, the other optional arms' missing-package remedy, turso's
never-read bound secret, and (`8875`, **404**) the mysql DSN branch. |
"the family of driver-factory arms, closed one driver at a time, that
each dropped something declared without a word: the optional-driver arms
that answered a missing package with no remedy, the turso arm that never
read its bound secret, and the mysql and mongo DSN branches that
discarded one" |
| `8873` | **404** — see Acceptance notes. | "the postgres equivalent is
judged on its own client's measurement, never inherited" |

**`filter-` (24 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `4706` | Maintainer, 2026-08-06, option B: retire `$regex` loudly and
add `$icontains`; five-backend true regex (A) excluded (turso remote
cannot, no business pull). | "the maintainer's 2026-08-06 ruling (option
B: retire `$regex` loudly and add `$icontains`, rather than make five
backends agree on one regex dialect)" |
| `5701`, `5702` | The contract half and the driver half of that ruling.
| "the retirement's own contract-half change"; "the contract half (the
`$icontains` declaration, …)"; "the driver half" |
| `6148` | **404** — see Acceptance notes. | "the gate that makes a
breaking changeset state its ADR-0087 disposition" |
| `18012` | **404** — see Acceptance notes. | "Maintainer ruling A of
2026-09-17: a blank `$between` endpoint is refused at the authoring
door, and the refusal names the blank side." |
| `13495` | driver-memory's reference matcher answered a null-bounded
range with every valued row. | "The reference matcher had already been
taught to survive the null-bound form of exactly this" |
| `objectui#9695` | The console filter builder stops padding a
half-typed pair with an empty string. | "is a change to the console's
own filter builder and lands on its own schedule" |
| `7596` | Maintainer, 2026-08-11, ADR-0049 REMOVE: no `{ $field }`
endpoint in either `$between` union. | "Maintainer ruling of 2026-08-11
on column-reference range endpoints, ADR-0049 enforce-or-remove:
REMOVE"; "reviewed and accepted with that ruling" |
| `7713` (PR) | Shipped that removal with a not-required disposition. |
"the 2026-08-11 changeset carried the disposition not-required" |
| `5222` | `$field` compiled to a column-to-column comparison on the SQL
faces. | "the position the column-to-column comparison compiles on every
face" |
| `19377` | **404** — see Acceptance notes. | "filed as an entry of its
own rather than as an already-registered rider" |
| `20116` | The collector for the family "the save door accepts
comparand shapes the query faces refuse", closed in stages. | "the
second stage of closing the family of comparand shapes the save door
accepted and the query faces refused"; "the family of comparand shapes
…" |
| `7872` | Maintainer, 2026-08-12: the shared face accepts `string` /
`number` / `bigint` / `boolean` / `null` / `Date` and refuses everything
else. | "the accepted set the maintainer ruled on 2026-08-12: string,
number, bigint, boolean, null and Date" |
| `19889` | Director seat, 2026-09-24, option A: the schema door refuses
what the compile face refuses (the standing array-equality refusal
applied to it). | "Ruled on 2026-09-24 (option A), applying the standing
refusal of an array in the equality slot to the schema door" |
| `19757` | Maintainer, 2026-09-23, option 乙: refuse an equality-slot
array at the shared face; 甲 (declare array equality) and 丙 (document the
divergence) rejected. | "Maintainer ruling of 2026-09-23 (option 乙 —
rather than declaring an array equality the SQL-family backends would
have to invent, or documenting a divergence that stays silent on one
backend)" |
| `19514`, `objectui#9050` | The protocol half of the maintainer's
2026-09-20 ruling C′ on the console's filter converter, rule 1 「the
differences are the protocol's to close」. | "The protocol half of the
maintainer's 2026-09-20 ruling (option C-prime) on the console's filter
converter, whose first rule reads, verbatim and untranslated: 「the
differences are the protocol's to close」" |
| `18113` | Published the text-comparand refusal predicate and reason
beside `FILTER_TEXT_CASES`. | "the pair published in this package beside
FILTER_TEXT_CASES for exactly this reason" |
| `19886` | Director seat, 2026-09-24, option A on standing text: `$ne`
with an array refused at the shared face and the schema door. | "Ruled
on 2026-09-24 by the director seat, on the standing contract text
(option A)" |
| `8690` | Ruled 2026-08-15: option B (engine door refuses an
uninterpretable temporal comparand) with option C (the preset refusal at
authoring) alongside. | "The authoring half (option C) of the
maintainer's 2026-08-15 ruling on uninterpretable temporal comparands,
ruled alongside the engine door (option B)"; "measured on the defect
report" |
| `8808` (PR) | The engine door. | "before the engine door and a 400
after" |
| `15661`, `15773` | Maintainer, 2026-09-05, C-deny: refuse a text
operator over a never-string declared type, over the existing type sets;
landed at the engine seam. | "Maintainer ruling of 2026-09-05 (option
C-deny: refuse now, over the type sets the contract already declares,
minting no new vocabulary), landed at the engine seam" |
| `14079` | Ruling A: a stored non-string value never satisfies a
positive text operator and satisfies `$notContains`. | "(a stored value
that is not a string never satisfies a positive text operator and
satisfies `$notContains`)" |

**`action-` (19 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `6748` | Retire `isAsync`: zero readers on a fresh three-repo
measurement. | "a fresh three-repo measurement (taken when the key was
filed for retirement, and re-run at pickup)" |
| `6667` | Enforce `supportsPause` at runtime. | "The sibling took the
ENFORCE leg of the same ruling" |
| `3801` | The generic resume route needs an authorization gate keyed on
the suspended node. | "The generic resume route's authorization gate
keys on the SUSPENDED NODE" |
| `3823` | The revise-window `wait` pause is service-owned but inherited
`'any'`. | "The revise-window incident decided the direction" |
| `4484`, `5540`, `6011` | The three retirements whose disposition this
one shares. | named by their entry ids, which the sentence already
carried |
| `5561` | `resumeAuthority` defaulted to `'any'` — fail-open by
omission. | the sentence already states it; ADR-0019's resume-seam
addendum named by date and title |
| `5703` | `supportsPause` / `isAsync` were zero-reader declarations. |
"no longer the declaration nothing enforced" |
| `5613` | Maintainer, 2026-08-06, contract-first ("C skeleton + A
semantics"): declare the shape as it stands, then rename on the typed
face. | "The maintainer ruled contract-first on 2026-08-06 (…: declare
the shape as it stands first, then rename on the typed face)"; "the
runtime half of the same ruling" |
| `5697`, `5779` | Phase 1 (the schema) and phase 2's spec half (the
canonical key and the alias). | "phase 1 declared …"; "`positions` is
now the canonical key …" |
| `5050` | The hook-side `roles` removed outright. | "(removed
outright)" |
| `3280`, `3290` | The hook `ctx.session.tenantId` alias: deprecated,
then removed in the next major. | "the hook `ctx.session.tenantId`
alias: deprecated first, removed in the next major" |
| `4579`, `4657` | The `openApi31` and `activationEvents` retirements. |
named by their surfaces, which the sentence already carried |
| `17319` | Ruled 2026-09-12, A: an action declares its dispatch
contract; B (unify the two wirings) refused. | "the 2026-09-12 ruling
that made an action declare its dispatch contract refused exactly that
option" |
| `14175` | The earlier typing fix that gave `find` the filter alone. |
"the parameter shape an earlier typing fix chose (the filter alone),
ruled by the director seat on 2026-09-12, with the maintainer's
agreement" |

**`data-` (15 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `4484`, `4618`, `4673` | The three retirements, each already stated by
its own entry. | trailing ids dropped (ADR ids kept) |
| `4639` | Multi-record predicate writes got `data.records.*` events. |
"since multi-record predicate writes were given events of their own";
"the way bulk writes were given theirs" |
| `3196` | Webhook triggers trimmed to producers that exist. | the
sentence already states it |
| `cloud#1053`, `cloud#1030` | **403 here** — see Acceptance notes. |
"20 such sites were measured in the downstream cloud codebase, and a
`$like` the type layer would have caught reached runtime there through
exactly that hole" |
| `6350` | The stock reconciliation of the v17 train's breaking
changesets against the ledger; this change was its control sample and
was itself an omission. | "Registered by the stock reconciliation that
compared the breaking changesets already on the v17 release train
against this ledger. This change was that audit's CONTROL sample …";
"(backfilled by that reconciliation)" |
| `5181` | The `DriverQuery` narrowing itself. | "this change"; "this
narrowing" |
| `6321`, `6083` | Two later call-parameter changes, both registered
(`6083` **404**; `main` records it as ADR-0122 phase 2). | "Two later,
smaller driver call-parameter changes both registered" |
| `18669` | Maintainer, 2026-09-17, ruling A: rename the last two
duration keys no closed duration type could express, no new closed type,
no narrowing. | "Maintainer ruling A of 2026-09-17 on the last two
duration keys no closed duration type could express" |
| `18122` | Published `DurationMs` / `DurationSeconds` beside `EpochMs`,
the unit set derived from six genuine duration rows. | "published beside
`EpochMs` as a closed duration type"; "one of the six genuine durations
the closed types' unit set was derived from" |
| `14478`, `15680` | Ruling B of 2026-09-02 on duration units; the
data-directory rename round. | "Maintainer ruling B on duration units
(2026-09-02)"; trailing ids dropped |

**`element-` (17 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `objectui#6206` | Maintainer, 2026-08-25, option B: one filter
orthography platform-wide — the `ViewFilterRule` array, not a
record-shaped exception; measurement-first binding. | "the maintainer's
2026-08-25 ruling, option B: …" (three entries); "the console-side half
of this convergence" |
| `15442`, `15449` | Ruled 2026-09-06, option A: converge the binding
and the four `object-*` doors family-wide, one entry; exception and
mixed rejected. | "ruled 2026-09-06, option A: converge the binding and
the four block doors family-wide, under one entry, rather than record an
exception"; "a gap measured on its own" |
| `7751` | Maintainer, 2026-08-12: the `object-*` block props enter
`ComponentPropsMap`. | "a read-point record derived from the renderers
on 2026-08-13, when the `object-*` blocks first got props schemas in the
map" |
| `objectui#6948` | The console's filter converter had no branch for
`$and` / `$or` / `$not`. | "(the console's filter converter had no
branch for them)" |
| `15828` | `POST /analytics/query` refused the array `where` the
adapter sent. | the sentence already states it; "the runtime route's
refusal of the array corrects it here" |
| `16626` | **404** — see Acceptance notes. | "parked behind a bump of
that pin" |
| `objectui#7754`, `objectui#7752`, `objectui#6828` | The console
adapter lowers an array analytics filter before the wire; `aggregate()`
runs `translateFilterArray`. | "the console adapter was changed so
`ObjectStackAdapter.aggregate()` runs the same `translateFilterArray`";
"The adapter-side lowering lands in the console's own repository." |
| `5158` | Maintainer, 2026-08-04, ruling C: `FilterArray` is input-only
sugar with one lowering seam (`parseFilterAST`). | "since the
maintainer's 2026-08-04 ruling C declared the array input-only sugar
with one lowering seam" |
| `5334` | The in-process analytics door, added when an array `where`
was silently dropped. | "(added when an array `where` was found silently
dropped on the analytics path)" |
| `17321` | Ruled 2026-09-12, B: a partial D2 conversion for the
losslessly mappable record forms; combinators pass through, named. |
"(ruled 2026-09-12, option B: convert what maps losslessly and name what
does not, rather than leave every stored row to its next save or flatten
combinators)" |
| `14406` | Converged `element:record_picker`; its census pin asks
whether any `filter` door refuses the array. | "the twin of the census
pin that asks whether any `filter` door still refuses the array"; the
2026-08-25 Option-A ordering ruling stated as "measure the consumer's
read path before the contract moves" |
| `12039` | `element:number` converged. | "after `element:number`
converged" |
| `objectui#7663` | The console registry's `inputs.filter` flip for the
picker. | "a console-side change filed in the objectui repository,
blocked on that release" |
| `15829` | The dashboard widget `filter` location, a finding of its
own. | "a different family, judged on its own" |

## Pin — `packages/cli/test/migrate-meta-engine-guidance.test.ts`,
widened

`COVERED_PREFIXES` is now `engine-`, `ui-`, `plugin-`, `driver-`,
`kernel-`, `system-`, `datasource-`, `filter-`, `action-`, `data-`,
`element-` (`data-` does not select `datasource-` or `dataset-`: the
match is `startsWith('data-')`). The `REWRITTEN` floor rises from **55
to 88** ids: the 33 entries of this stage that carried a tracker id. The
three `it` blocks are textually unchanged: the detector is exercised on
both sides, every covered prefix must select an entry, and each covered
block is found verbatim in the real CLI's stdout before it is asserted
clean. The file keeps its stage-1 name; the header lists the eleven
covered families.

## Ablation — the widened pin can fail on a new-family block

From committed state, HEAD `d8bcc46a`, in one lock turn, with
`scripts/ablation-replace.mjs` in wrap mode (it owns the mutation's
restore trap; the leg script adds its own `trap … EXIT INT TERM` that
restores `registry.ts` from `HEAD` by absolute path and checks the blob)
and `scripts/ablation-dist-preflight.mjs` gating each leg. The bundle is
built from the generated `registry.ts`, so that is the file mutated.
- **Mutation.** In `registry.ts`, the `acceptanceCriteria` of
`datasource-credentialsref-mongo-url-no-user-refused`: anchor `parse
reports this URL-branch refusal.` → `parse reports the objectstack-ai#9041 refusal.`
The tool read anchor 1 → 0 and replacement 0 → 1, blob `7001c102` →
`6f3f81ff`.
- **Mutate leg.** Spec build exit 0. Preflight: marker present in 4
built files. Pin: **red**, `1 failed | 2 passed` —
`datasource-credentialsref-mongo-url-no-user-refused: the printed
guidance cites a tracker id: expected 'objectstack-ai#9041' to be undefined`.
- **Restore.** Tool-proven: blob `7001c102` == HEAD, `git diff HEAD`
empty.
- **Restore leg.** Spec build exit 0. The `--absent` preflight found the
marker in none of 224 built files, with the working tree clean against
HEAD. Pin: **green**, `3 passed`. Whole tree afterwards: 0 dirty paths.

## Verification

Final head **`d8bcc46a`**. Every heavy run went through
`scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0` on each turn;
per-step exit codes recorded separately). Where a line was taken at
`95fb97ea` it says so: the one commit after it (`d8bcc46a`) changes one
entry sentence and the three generated projections, and every reader of
that text was re-run at `d8bcc46a`.

- **Build:** `pnpm exec turbo run build --concurrency=2
--filter='@objectstack/cli^...'` gives `Tasks: 58 successful, 58 total`
(at `95fb97ea`); the spec package was rebuilt at `d8bcc46a` in both
ablation legs (exit 0).
- **Pin:** at `d8bcc46a`, the ablation's restore leg:
`test/migrate-meta-engine-guidance.test.ts` `3 passed`. With its
neighbour at `95fb97ea`: `pnpm --filter @objectstack/cli exec vitest run
--project integration --maxWorkers=2
test/migrate-meta-engine-guidance.test.ts
test/migrate-meta-default-range.test.ts` gives `Test Files 2 passed`,
`Tests 10 passed | 1 skipped` (the skip is the default-range file's own
`skipIf`). Re-run with its neighbour at `d8bcc46a`: `Test Files 2
passed`, `Tests 10 passed | 1 skipped`. One earlier run at this head is
void and is not counted: it spawned the CLI while a concurrent lock-free
gate was rebuilding the package `dist/` trees (`MODULE_NOT_FOUND`, both
files); the re-run is the reading.
- **Spec, the whole `local` project** (at `95fb97ea`): `pnpm --filter
@objectstack/spec exec vitest run --project local --maxWorkers=2` gives
`Test Files 572 passed (572)`, `Tests 16789 passed | 1 todo`. **The
`repo` project** (at `d8bcc46a`): `Test Files 38 passed`, `Tests 690
passed`. **The readers of the changed sentence** (at `d8bcc46a`):
`src/migrations`, `src/ui/action-params.test.ts`,
`src/ui/filter-rule-array-guidance.test.ts` give `Test Files 5 passed`,
`Tests 229 passed`.
- **CLI unit** (at `95fb97ea`, no CLI file changed after):
`test/vitest-tiers-partition.test.ts` and
`src/utils/spec-release-changes.test.ts` give `Test Files 2 passed`,
`Tests 28 passed`.
- **The call-spelling census that reads `registry.ts`:** `pnpm --filter
@objectstack/driver-sql exec vitest run --maxWorkers=2
src/sql-driver-query-signature.test.ts` gives 15 passed.
- **Typecheck** (at `95fb97ea`; the last commit edits string literals
only): `pnpm --filter @objectstack/spec typecheck` exits 0 (test layer:
53 files / 251 errors held in its ledger); `pnpm --filter
@objectstack/cli typecheck` exits 0 (3 files / 28 errors held).
- **Gate families:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derives **90** families at `d8bcc46a`
(the same set it derived at `95fb97ea`). `--ran` over the recorded exit
codes reads **90 derived, 90 run, 0 NOT-MEASURED, 0 UNRUN**, all exit 0.
They include `check:doc-authoring` ("16735 customer-facing string(s)
across 1167 spec sources clean"), `check:issue-citations`,
`check:migration-registry` ("registry.ts is current (310 semantic, 230
retired-key, 206 retired-def)"), `check:spec-changes`,
`check:upgrade-guide`, `check:generated` ("All 15 generated artifacts
are up to date"), `check:org-identifier`, `check:nul-bytes`,
`check:dual-build-cjs-loads` (104 require entry points across 66
packages load), `check:type-check-debt`, `check:adr-0087-registration`
and `check:changeset-no-major`.
- How the reading was assembled: a container restart cut the pass at
`d8bcc46a` after 30 families; the other 60 were run afterwards on the
same head, and `check:type-check-debt`, whose first resumed run lost a
package build to an OOM kill (exit 3, `PREREQUISITE NOT MET`), was
re-run alone and exited 0 ("4 ledger entr(ies) re-measured … none above
its recorded number").
- The earlier full pass at `95fb97ea` read 88 exit 0,
`check:dual-build-cjs-loads` exit 3 (dists not yet built) and
**`check:org-identifier` exit 1**: my rewrite of the action-session
entry had spelled the removed hook read literally. `d8bcc46a` names that
alias in words instead, and the gate reads OK there.
- **Lint (a proven narrowing, not the repo-wide run, which is CI's):**
`eslint --no-inline-config --format json` over the 36 changed `.ts`
files at `d8bcc46a` reports 36 files, 0 errors, 0 warnings.
- The population is read from `eslint.config.mjs`:
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`, and all 36
are in it (no file-ignored warning).
- Invariance: the config enables no type-aware linting (no
`parserOptions.project`, no typed rules), so a text edit cannot move the
verdict on a file it does not touch.
- **Mergeability:** `main` moved 10 commits past the base, to
`4a1df196`; the only ones under `packages/spec/src/migrations/` are the
landed `20488` (two entries in the `field-` and `ui-` families, plus
`registry.ts`). A driver-free bare-clone `merge-tree --write-tree` of
`d8bcc46a` against `4a1df196` exits 0 with no conflicted path, and the
census over that merged tree reads 0 sites in all eleven covered
families, so `main` was not merged in; CI's merge ref runs the registry
gates on the merged tree.

## Acceptance notes

- **Ten dead ids, rewritten from the code on `main`.** Each answers 404
on both the issues and the pulls endpoint, with a 200 control (`14478`).
- `8696` / `8875` / `8873` (the bound secret reaching the mongo, mysql
and postgres clients): `service-datasource`'s
`default-datasource-driver-factory.ts`, its CHANGELOG (the mongo
URL-branch entry says it "closes the last arm of the family" the other
cards closed one driver at a time; the mysql DSN fix is `{ uri, password
}`) and `bound-secret-dsn-branches.test.ts`, which pins the bound secret
outranking `options.auth`. `common.zod.ts` and
`pg-url-grammar.server.ts` record why the shared helpers must not parse
(mongo's multi-host form, which `new URL()` rejects).
- `8876`: `common.zod.ts` (`urlUserinfoUsername`, the username half of
the same grammar; a username is not credential material).
- `9041`: `datasource.zod.ts`
(`CREDENTIALS_REF_MONGO_URL_NO_USER_REFUSED` and its docblock); the text
now names the sibling entry by id.
- `6148`: `scripts/check-adr-0087-registration.mjs`'s header (a
declared-breaking changeset must state its ADR-0087 disposition in
writing).
- `18012`: `.changeset/18012-between-blank-endpoint-refused.md` and
`filter.zod.ts` (ruling A of 2026-09-17: a blank bound refused at the
authoring door, the blank side named).
- `19377`: `.changeset/19377-between-field-endpoint-runtime-door.md` and
`filter-comparand-shape.ts` (the runtime door brought under the
2026-08-11 removal).
- `16626`: `.changeset/filter-orthography-binding-and-object-blocks.md`
on `main` records it as the objectui pin bump the element convergence
was parked behind; the text now says "a bump of that pin", and the pin
sha it names is unchanged.
- `6083`: `main` records it as ADR-0122 phase 2 (the spec CHANGELOG);
the sentence keeps its claim ("two later, smaller driver call-parameter
changes both registered") without the numbers.
- **Two cross-repo ids this session cannot read.** `cloud#1053` and
`cloud#1030` answer 403 (`objectstack-ai/cloud` is not reachable here,
and attaching it was refused). The sentence was rewritten from what this
repository records: `packages/spec/CHANGELOG.md` (the published
`DriverQuery` entry: 20 measured `as any` sites downstream, and a
`$like` that reached runtime through that hole) and the `5181` card body
("cloud 实测 20 处 cast … cloud#1030 的 `$like` 本可在类型层拦住"). Nothing the
cloud cards add beyond that is claimed.
- **Short numbers and ruling-record ids went too (invisible to the
regex).** Six decision-batch numbers (`objectstack-ai#43` ×2, `objectstack-ai#55`, `objectstack-ai#123`, `objectstack-ai#146`,
`objectstack-ai#151`), three ruling-record comment ids and one `batch 217 item 3`
spelling are numbers an author is shown and cannot follow, so each is
dropped. So are the maintainer's batch acknowledgements 「146 同意」 and
「217 同意」 and the bare 「同意」 beside `objectui#6206` (×3), `15442` and
`14175`: they record only that a batch was approved, and each sentence
now states the ruling's date and content instead. The one verbatim quote
that carries a lesson, 「the differences are the protocol's to close」, is
kept untranslated.
- **"issue NNNN" / "PR NNNN" spellings, checked by hand.** A scan of the
five families' evaluated prose for any run of three or more digits and
for `issue` / `card` / `PR` / `batch` / `record` / `summon` plus a
number now finds only HTTP statuses, ports and example values. The two
`PR #…` citations were `#`-spelled, so the instrument saw them.
- **One test pinned a removed tracker number.**
`packages/spec/src/ui/action-params.test.ts` asserted `reason` matches
the `5613` number; it is re-pinned on the sentence that carries the
ruling (`/ruled contract-first/`). No other test reads these entries'
prose for a tracker id (a grep of test files for the 88 cited numbers
finds only comment lines and assertions on other runtime strings).
- **No open PR touches these five families.** Read twice. At
2026-09-28T19:04Z: 11 open PRs' file lists (227 rows). Again at 20:28Z,
just before opening this one: 8 open PRs (195 rows; the Version Packages
PR `17076` skipped both times), none carrying a
`migrations/entries/semantic/NN.(datasource|filter|action|data|element)-*`
file. PRs `20504`, `20503`, `20460` and `20458` add or edit entries in
other families (`turso-`, `view-`, `stack-`, `cube-`) and regenerate
`registry.ts`: ordinary concurrency, regenerate on merge. Widening the
pin reds no sibling.
- **Generated projections** (`spec-changes.json`,
`docs/protocol-upgrade-guide.md`) are regenerated, as in stages 1–3;
only the protocol-17 entries appear in them.
- **What later stages pick up** (whole tree at this head, same
instrument): **571** prose-field sites in the other families, **61**
short `#NN` sites, **7** `surface` sites, and three `cloud#` sites
(`storage-service-list-retired`, `cloud-subpath-retired`,
`cluster-driver-dangling-values-removed`; the fourth,
`data-driver-query-omit-object`, is done here).
- **Noted, not filed — the same rule outside this card's fields.**
Runtime strings outside the migration entries still carry tracker
numbers. One read in passing: `AutomationEngine`'s boot warning for a
pausing node type that never declares `resumeAuthority`
(`packages/services/service-automation/src/engine.ts`, the line that
reads `so the objectstack-ai#3801 resume gate REFUSES every pause it creates`) is
printed to a plugin author, and `resume-authority-declaration.test.ts`
asserts the `3801` and `3823` numbers are in it. That is the
runtime-string rule this card applies, on a producer outside its
surface; it is reported to the seat, not fixed here.

## Line budget

Entry files: **367 changed lines** (+214 / −153) across 33 files,
against the stage-1 ≈400 budget. The whole diff is **871 lines** (+527 /
−344) in 39 files. Of the rest, `registry.ts` is 367, the two
projections are 68 (`spec-changes.json` 44, the upgrade guide 24), the
widened pin is 42, the re-pinned test 2 and the changeset 25.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH)_

---------

Co-authored-by: Claude <noreply@anthropic.com>

This branch had an error being deployed

1 failed deployment
Preview — 3dd6ea56 Deployed Jan 21, 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/xl tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Please perform a final refactoring on PR #46 to consolidate the Authentication Architecture and resolve conflicts with the existing identity.zod.ts.

3 participants