Skip to content

Add metadata extension protocol for plugin extensibility - #215

Closed
hotlong with Copilot wants to merge 4 commits into
mainfrom
copilot/design-metadata-definition-extensions
Closed

hotlong with Copilot wants to merge 4 commits into
mainfrom
copilot/design-metadata-definition-extensions

Conversation

Copilot AI commented Jan 26, 2026 •

Copy link
Copy Markdown
Contributor

Plugins need a standard way to extend core metadata (Objects, Fields) with custom properties without modifying base schemas.

Changes

Extension Protocol (src/system/extension.zod.ts)

  • ExtensionsMap: Namespaced key-value store (plugin_id.property_name)
  • ExtensionDefinition: Metadata registry for declaring extension schemas
  • Extension utilities: Type-safe helpers (get, set, has, remove)
  • Recursive type-safe schema supporting nested objects/arrays

Schema Integration

  • Added optional extensions field to FieldSchema and ObjectSchema
  • Fully backward compatible - existing schemas unchanged

AI Extensions (Reference Implementation)

  • Field extensions (9): Vector indexing, auto-summarization, sentiment analysis
  • Object extensions (15): RAG enablement, AI agents, predictive analytics, auto-classification

Usage

import { ObjectSchema, Extension } from '@objectstack/spec';

// Extend a field with AI capabilities
const field = {
  name: 'description',
  type: 'textarea',
  extensions: {
    'ai_assistant.vectorIndexed': true,
    'ai_assistant.embeddingModel': 'text-embedding-3-small',
    'ai_assistant.chunkSize': 512
  }
};

// Extend an object with RAG and agent support
const object = {
  name: 'customer_inquiry',
  fields: { /* ... */ },
  extensions: {
    'ai_assistant.enableRAG': true,
    'ai_assistant.contextFields': ['title', 'description'],
    'ai_assistant.agentEnabled': true,
    'ai_assistant.agentName': 'support_assistant'
  }
};

// Type-safe access
const ragEnabled = Extension.get(object.extensions, 'ai_assistant.enableRAG', false);

Documentation

  • User guide: content/docs/guides/metadata-extensions.mdx
  • Example: examples/ai-customer-service/ (complete AI-powered support system)
Original prompt

设计一套规范如何扩展标准的元数据定义,比如我需要开发一个AI插件,给对象或字段上扩展新的属性


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

@vercel

vercel Bot commented Jan 26, 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 26, 2026 5:27am

Request Review

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Design a specification for extending standard metadata definitions Add metadata extension protocol for plugin extensibility Jan 26, 2026
Copilot AI requested a review from hotlong January 26, 2026 05:29
@hotlong
hotlong marked this pull request as ready for review January 27, 2026 05:16
Copilot AI review requested due to automatic review settings January 27, 2026 05:16

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

Adds a standardized “extension” protocol so plugins can attach namespaced custom metadata to core definitions (e.g., Objects/Fields) without modifying base schemas.

Changes:

  • Introduces src/system/extension.zod.ts (extension value/map/definition/registry schemas + helper utilities) and accompanying tests.
  • Integrates optional extensions into FieldSchema and ObjectSchema, plus tests validating extended metadata.
  • Adds AI extension reference definitions (field/object) with schemas/tests, and updates generated JSON Schemas + docs + a new example.

Reviewed changes

Copilot reviewed 38 out of 38 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
packages/spec/src/system/index.ts Exposes the new extension protocol via the System barrel export.
packages/spec/src/system/extension.zod.ts Defines extension schemas and helper utilities for reading/writing extensions.
packages/spec/src/system/extension.test.ts Adds unit tests for extension schemas and helpers.
packages/spec/src/data/object.zod.ts Adds extensions support to Object metadata.
packages/spec/src/data/object.test.ts Tests Objects with/without extensions and complex extension values.
packages/spec/src/data/field.zod.ts Adds extensions support to Field metadata.
packages/spec/src/data/field.test.ts Tests Fields with/without extensions and complex extension values.
packages/spec/src/ai/object-extensions.zod.ts Provides AI-oriented Object extension definitions + schema.
packages/spec/src/ai/index.ts Exports AI extension definitions/schemas from the AI barrel.
packages/spec/src/ai/field-extensions.zod.ts Provides AI-oriented Field extension definitions + schema.
packages/spec/src/ai/extensions.test.ts Tests AI extension definitions and end-to-end schema integration.
packages/spec/json-schema/ui/FieldWidgetProps.json Updates generated UI schema to include extensions on fields.
packages/spec/json-schema/system/ExtensionsMap.json Adds generated JSON schema for ExtensionsMap.
packages/spec/json-schema/system/ExtensionValue.json Adds generated JSON schema for ExtensionValue.
packages/spec/json-schema/system/ExtensionRegistry.json Adds generated JSON schema for ExtensionRegistry.
packages/spec/json-schema/system/ExtensionDefinition.json Adds generated JSON schema for ExtensionDefinition.
packages/spec/json-schema/kernel/Manifest.json Updates generated Manifest schema to include extensions in embedded object/field definitions.
packages/spec/json-schema/hub/ComposerResponse.json Updates generated Hub schema to include extensions in embedded object/field definitions.
packages/spec/json-schema/data/Object.json Updates generated Object schema to include extensions.
packages/spec/json-schema/data/Field.json Updates generated Field schema to include extensions.
packages/spec/json-schema/ai/AIObjectExtension.json Adds generated schema for AI Object extension keys.
packages/spec/json-schema/ai/AIFieldExtension.json Adds generated schema for AI Field extension keys.
examples/ai-customer-service/index.ts Demonstrates adding extensions to an object/fields and validating with schemas.
examples/ai-customer-service/README.md Documents the AI customer service example usage.
content/docs/references/system/meta.json Registers the new System Extension reference section in docs navigation.
content/docs/references/system/extension/meta.json Adds docs metadata for the Extension reference section.
content/docs/references/system/extension/ExtensionsMap.mdx Adds reference doc stub for ExtensionsMap.
content/docs/references/system/extension/ExtensionValue.mdx Adds reference doc stub for ExtensionValue.
content/docs/references/system/extension/ExtensionRegistry.mdx Adds reference doc for ExtensionRegistry.
content/docs/references/system/extension/ExtensionDefinition.mdx Adds reference doc for ExtensionDefinition.
content/docs/references/data/object/Object.mdx Updates Object reference to mention the new extensions property.
content/docs/references/data/field/Field.mdx Updates Field reference to mention the new extensions property.
content/docs/references/ai/object-extensions/meta.json Adds docs metadata for AI Object extensions reference section.
content/docs/references/ai/object-extensions/AIObjectExtension.mdx Adds reference doc for AI Object extension keys.
content/docs/references/ai/meta.json Registers AI extension reference pages in AI docs navigation.
content/docs/references/ai/field-extensions/meta.json Adds docs metadata for AI Field extensions reference section.
content/docs/references/ai/field-extensions/AIFieldExtension.mdx Adds reference doc for AI Field extension keys.
content/docs/guides/metadata-extensions.mdx Adds a full guide describing how to use the extension protocol and helpers.

Comment on lines +254 to +256
has: (extensions: ExtensionsMap | undefined, key: string): boolean => {
return extensions ? key in extensions : false;
},

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

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

Extension.has uses the in operator, which returns true for inherited keys like toString even when the extension was never set. Use an own-property check instead (and consider using a null-prototype object for extension maps) to avoid false positives.

Copilot uses AI. Check for mistakes.
Comment on lines +73 to +76
export const ExtensionsMapSchema = z.record(
z.string().describe('Namespaced extension key (e.g., "plugin_id.property_name")'),
ExtensionValueSchema.describe('Extension value (string, number, boolean, object, or array)')
).optional().describe('Custom extension properties from plugins and modules');

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

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

ExtensionsMapSchema is defined as .optional(), which makes the standalone ExtensionsMap type include undefined and produces an awkward top-level JSON Schema (e.g. anyOf with not: {}). Consider making ExtensionsMapSchema the non-optional record, and apply .optional() only at usage sites (Field/Object). Also consider validating keys with a namespaced-key regex (matching ExtensionDefinitionSchema) rather than plain z.string() to enforce the protocol.

Copilot uses AI. Check for mistakes.
Comment on lines +129 to +133
/**
* Default value if not specified
*/
default: z.any().optional().describe('Default value for this extension'),

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

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

default is z.any(), which allows non-JSON values even though extension values are documented as JSON-compatible. Consider validating default with ExtensionValueSchema (and similarly restricting schema to an object shape if it’s meant to hold JSON Schema).

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +29
```typescript
import { ExtensionsMap } from '@objectstack/spec';

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

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

The examples in this guide import ExtensionsMap (and later FieldSchema, ObjectSchema, Extension, etc.) directly from @objectstack/spec, but the package root currently only exports namespaces (see packages/spec/src/index.ts). Update the snippets to use the documented namespace import (import { System, Data, AI } from '@objectstack/spec') or direct subpath imports like @objectstack/spec/system / @objectstack/spec/data so they match the actual public API.

Copilot uses AI. Check for mistakes.

### Key Concepts

1. **Namespaced Keys**: All extensions use dot notation: `plugin_id.property_name`

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

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

The guide states extension keys use plugin_id.property_name, but elsewhere (and in the schemas) the convention is namespace.property with the property segment in camelCase. Align this description with the intended convention (e.g. plugin_id.propertyName) to avoid encouraging snake_case property segments.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +224 to +227
get: <T = any>(extensions: ExtensionsMap | undefined, key: string, defaultValue?: T): T | undefined => {
if (!extensions) return defaultValue;
return (extensions[key] as T) ?? defaultValue;
},

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

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

Extension.get reads extensions[key] directly, which can return inherited prototype properties (e.g. toString) instead of the provided default. Guard with an own-property check (e.g. Object.hasOwn / hasOwnProperty.call) before returning the stored value, otherwise fall back to defaultValue.

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +105
key: z.string()
.regex(/^[a-z_][a-z0-9_]*\.[a-zA-Z][a-zA-Z0-9_]*$/)
.describe('Fully qualified extension key (e.g., "ai_assistant.vectorIndexed")'),

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

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

The key regex and nearby docs allow property_name and a property segment starting with uppercase, but the file also states the property should be camelCase. Tighten the regex/examples so the namespace is snake_case and the property segment is lowerCamelCase (and keep docs consistent with that convention).

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +137 to +139
appliesTo: z.array(z.enum([
'object', 'field', 'view', 'app', 'dashboard', 'report', 'action', 'workflow'
])).describe('Metadata types this extension can be applied to'),

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

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

appliesTo is required but can currently be an empty array, which makes an ExtensionDefinition effectively meaningless. Consider using a non-empty array constraint (e.g. .nonempty()) so every definition declares at least one applicable target.

Copilot uses AI. Check for mistakes.
Comment on lines +189 to +192
extensions: z.record(
z.string(),
ExtensionDefinitionSchema
).describe('Registry of available extension definitions'),

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

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

ExtensionRegistrySchema.extensions allows any string as the record key, so the map key can diverge from ExtensionDefinition.key. Consider constraining the record key with the same key regex and adding a refinement that enforces recordKey === definition.key (and optionally that definition.pluginId matches the namespace prefix).

Copilot uses AI. Check for mistakes.
* Demonstrates ObjectStack metadata extensions for AI capabilities
*/

import { ObjectSchema, Extension } from '@objectstack/spec';

Copilot AI Jan 27, 2026

Copy link

Choose a reason for hiding this comment

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

This example imports ObjectSchema and Extension from @objectstack/spec, but the package root entrypoint currently only exports namespaces (e.g. Data, System). Use namespace imports (import { Data, System } from '@objectstack/spec') or subpath imports (@objectstack/spec/data, @objectstack/spec/system) so the example can actually run.

Copilot uses AI. Check for mistakes.
@hotlong hotlong closed this Jan 27, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…fused at parse, no longer enforced on writes (objectstack-ai#19909)

Fixes objectstack-ai#19629

Clause-②: no (narrowing)

Executes maintainer ruling `5791803339` (batch objectstack-ai#215 item 1, letter B,
「215 同意」): `scale` is retired from the `currency` field type. Its
refusal remedy and its ADR-0087 migration entry are worded by maintainer
ruling 乙 `5805782503` (batch objectstack-ai#218 item 2, 「其他同意」), item 1:

> PR objectstack-ai#19909's refusal remedy and ADR-0087 migration entry are reworded:
delete `scale` on a currency field; the currency's ISO 4217 minor unit
decides its display, and its write allowance stays unconstrained
(today's contract). ⛔ No pointer to `currencyConfig.precision`.

The consumer half is objectstack-ai/objectui#10221 and is not in this
diff: the field designer, the grid summary footer, and, under ruling 乙
item 2, the dashboard `ObjectMetricWidget`. B′ (enforcing a currency
width on writes) was not taken and is not here.

## Landing order (ruling 乙 item 2)

This PR lands **after** objectstack-ai/objectui#10221 has landed
(widened to `ObjectMetricWidget`) **and after** this repository's
`.objectui-sha` pin bump has moved past it. That order keeps any
zero-decimal window from opening on the grid summary footer or the
dashboard metric widget. At this writing the pin is `62597c588072` and
objectui#10221 is open with no PR (both re-read in round 3). Holding the
landing to that order is the seat's.

## What changes

1. **`@objectstack/spec`, `FieldSchema`**
(`packages/spec/src/data/field.zod.ts`): `scale` on a `type: 'currency'`
field is refused at parse, with one `custom` issue at `scale` (the
file's per-type `superRefine` house pattern). No alias and no grace
window. The remedy's first sentence is "`scale` is not valid on a
`currency` field — delete the key." It goes on to say that the
currency's ISO 4217 minor unit decides how the amount displays, and that
the field's write allowance stays unconstrained. It names no other key
to carry the value. The `scale` describe names the set the key still
applies to: `number`, `percent`, `rating` and `slider`, enforced on
writes, and `formula`, rounded. On `currency` it now reads "REFUSED on a
`currency` field — delete it there".
2. **`@objectstack/objectql`, record validator**
(`packages/objectql/src/validation/record-validator.ts`): the
`max_scale` branch no longer reads `def.scale` for `currency`, so the
type leaves the enforced set. `min`, `max` and the finite-number check
still apply to currency. `percent`, `number`, `rating` and `slider` are
unchanged. Nothing else is read in its place: a currency's write
allowance stays unconstrained.
3. **ADR-0087 semantic entry** `field-currency-scale-refused`
(`packages/spec/src/migrations/entries/semantic/18.field-currency-scale-refused.ts`).
Its replacement is "DELETE the key — that is the whole migration", and
it says nothing replaces the key. It is deliberately not a mechanical
conversion: a conversion that dropped the key would accept it on every
load, which is the grace window ruling B refused. `registry.ts` is
regenerated with `gen:migration-registry`, never hand-edited.
4. **Shipped examples** that the refusal would reject. `scale: 2` was
deleted from each, and nothing was added:
- `examples/app-crm/src/objects/account.object.ts`,
`opportunity.object.ts`, `opportunity-line-item.object.ts` (×2)
- `examples/app-showcase/src/data/objects/account.object.ts`,
`client-brief.object.ts`, `expense-report.object.ts`,
`external/customer.object.ts`, `external/order.object.ts`,
`field-zoo.object.ts`, `invoice.object.ts` (×3), `project.object.ts`
(×2)
5. **Documentation examples.** `scale: 2` was deleted from 13 currency
examples in `content/docs/concepts/architecture.mdx` (×2),
`concepts/metadata-driven.mdx` (×2), `data-modeling/fields.mdx`,
`data-modeling/objects.mdx`, `data-modeling/schema-design.mdx`,
`protocol/kernel/plugin-spec.mdx`, `protocol/objectql/schema.mdx` (×4)
and `protocol/objectql/types.mdx`. The `schema.mdx` property table's
`scale` row no longer lists `currency`. It now says "Refused on
`currency` — delete it there".
6. **Generated**, never hand-edited:
`content/docs/references/data/field.mdx`,
`content/docs/references/data/object.mdx` and
`content/docs/references/system/migration.mdx` (the `scale` describe).
`authorable-surface`, `api-surface`, `json-schema.manifest`,
`spec-changes.json` and the upgrade guide did not move, because no key
or export was added or removed. The nine `*.metadata-forms.generated.ts`
i18n bundles did not move either: round 3 changes a `visibleWhen`, not a
label or help text, and those bundles carry only label and help text.
7. **`packages/spec/src/data/field-scale.ts`**: the docblock's
`currency` bullet records the retirement and ruling 乙's remedy. It no
longer names `CurrencyConfigSchema.precision` as the money faces'
override; that claim was false at the pin.
8. **`packages/spec/src/data/object.form.ts`** (round 3): the object
designer's quick-add fields grid, `objectForm`, is the form
`METADATA_FORM_REGISTRY` serves for the `object` metadata type. Its
`scale` row's `visibleWhen` was `"data.type in
['number','currency','percent']"` and is now `"data.type in
['number','percent']"`, with a one-line comment citing this card and
ruling `5791803339` B. The `precision`, `min` and `max` rows are
unchanged.

## Measured first, on `origin/main` `1f89ba0d70` (built `dist`)

- A currency field with `scale: 2` **parsed**: `FieldSchema.safeParse`
succeeded. Control: the same with `type: 'number'` also succeeded.
- The record validator **refused** a currency write over `scale`:
`scale: 2`, write `1.234` → `max_scale {"scale":2,"actual":3}`. A
currency field without `scale` accepted `1.23456`.
- `max_scale` read `def.scale` on **all four** controls. Each was given
`scale: 2` and the write `1.23456`. `number`, `rating` and `slider`
answered `{scale:2, actual:5}`. `percent` answered `{scale:4,
actual:5}`, because of the fraction-storage derivation.
- **Population census**, a TypeScript AST sweep over every tracked
`.ts`/`.tsx`/`.mjs`/`.js` file (7,036 files): **15 example sites**, all
listed above, and **7 test fixtures**, listed below. A JSON walk over
556 tracked `.json` files found 0 currency-typed objects with `scale`. A
windowed scan of 831 `.md`/`.mdx` files found the 13 documentation
examples. The one surviving AST hit is
`packages/spec/src/data/inline-related-columns.test.ts:103`. It is
`InlineGridColumnSchema.scale`, a different schema that the ruling does
not cover.

## Readings the round measured, and where each went

1. **`currencyConfig.precision` has no reader.** No face in the pinned
console (`.objectui-sha` `62597c588072`) reads it, and no non-test code
in this repository does. The cell uses the currency's ISO 4217 minor
unit, and the edit widget reads the **field-level** `precision`. →
Ruling 乙 item 1: the remedy and the migration entry no longer point to
that key. Two findings follow from ruling 乙 item 3:
`currencyConfig.precision` is declared and read by nothing, and
objectui's `CurrencyField` reads the field-level `precision` as decimal
places. Both are filed by the `domain:spec` seat, as objectstack-ai#19992 and
objectstack-ai/objectui#10276, ⛔ not riders here.
2. **Ruling B's control reading undercounted the examples.** Fifteen
`Field.currency` declarations in `examples/` declared `scale: 2`. →
Corrected on the card by `5805294161`.
3. **A fourth face.** The dashboard `ObjectMetricWidget`'s currency arm
reads `valueFieldDef.scale ?? 0`. → Ruling 乙 item 2: objectui#10221 is
widened to it and lands first (see the landing order above).
4. **Direction of the validator half.** It moves writes from refused to
accepted. → Ruling 乙 item 4: `Clause-②: no (narrowing)` stays as ruled.
The widening reading from the objectstack-ai#19320 precedent is noted and ⛔ not
re-declared.

## Round 3, on top of `40ef043003`

**Why.** The at-tier contract-review record `5818558748` on this PR
(head `40ef043003`) read **VERDICT: FAIL** with one required fix:
`packages/spec/src/data/object.form.ts:190` still offered `scale` on a
currency row of the object designer's fields grid, the form registered
in `packages/spec/src/system/metadata-form-registry.ts`. After this PR
an author using that form is shown a `scale` input on a currency field
and is refused at publish, which is the offer-vs-door shape this
retirement removes.

- **`d7740c324e`**: the fix exactly as the record states it. The `scale`
row's `visibleWhen` is now `"data.type in ['number','percent']"`, with
the one-line comment. The row was not widened to any other type, and the
`precision`, `min` and `max` rows were not touched. The same commit adds
the offer-side pin and one changeset sentence (both below).
`origin/main` was not merged: the PR reads `mergeable_state: clean`, and
no file main changed since the merge base `c8399867b8` is a file this
branch changes (`comm` of the two name lists is empty), so CI's merge
ref needs nothing from a merge here.

**Sweep of `packages/spec/src/**` for any other surface offering `scale`
on `currency`.** All 17 `*.form.ts` files were read for `scale` rows,
and every non-test source file naming both `scale` and `currency` was
read at the hit.

| file:line (HEAD `d7740c324e`) | what it is | changed? |
|:--|:--|:--|
| `data/object.form.ts:191` | the `objectForm` fields-grid `scale` row
(it was `:190`) | **yes**, the record's fix |
| `data/field.form.ts:79` | the `fieldForm` `scale` row, `"data.type ==
'number'"` | no; it does not offer the key on currency (the record's own
control) |
| `data/field.zod.ts:1235-1236` | `FieldSchema.scale` and its describe,
"REFUSED on a `currency` field" | no; that is the door, from round 2 |
| `data/field.zod.ts:1025` | `FieldSchema` aliases `decimals` /
`decimalPlaces` → `scale` | no; on currency the renamed key lands on the
same refusal (acceptance note below) |
| `data/field.zod.ts:420` | `CurrencyConfigSchema` alias `scale` →
`precision`, inside `currencyConfig` | no; a different schema,
pre-existing, and the surface of objectstack-ai#19992 (acceptance note below) |
| `data/field.zod.ts:931` | `InlineGridColumnSchema.scale` | no; a
different schema, left alone as instructed |
| `ui/view.zod.ts:3338` | `FormFieldSchema.scale`, a form-view row's
widget override with no type gate by design (a form row usually omits
`type`) | no; a different schema, and its describe names no field type |
| `ui/view.zod.ts:1600` | the timeline view's `scale` (`hour` … `year`)
| no; unrelated |
| `ui/bulk-action.zod.ts:157` | `scale` in
`BULK_PARAM_WIDGET_CONFIG_KEYS`, a list of keys bulk params REFUSE | no;
a refusal list, not an offer |
| `data/field-scale.ts:101` | `ABSENT_SCALE_BY_TYPE`, whose one row is
`percent` | no; there is no currency row |
| `data/numeric-column-representation.ts:49-52` | a dated measurement
docblock that names a `currency` field with `scale: 2` refused at the
write seam | no; a record of a reading, not an offer (acceptance note
below) |
| `studio/**` | designer hints | 0 `currency` hits |
|
`packages/platform-objects/src/apps/translations/*.metadata-forms.generated.ts`
(outside `packages/spec/src`) | the generated form bundles;
`fields.scale` carries only its label and help text (`en` `:147-150`) |
no; no predicate lives there, and `check:i18n` reads all nine packages'
bundles in sync |

A quoted-exact `git grep -n -F "['number','currency','percent']"` over
the whole tree now answers one line, the `precision` row at
`object.form.ts:189`, which the record says to leave. Before the fix it
answered that row and the `scale` row.

**The new pin**
(`packages/spec/src/data/field-currency-scale-refused.test.ts`, final
block). It evaluates each row's `visibleWhen` the way
`form-delete-behavior-options.test.ts` already does: a fail-closed
reader of the `data.type` spellings these forms use (`==` and `in
[...]`, joined by `||`) that throws on anything else, so a predicate it
cannot read fails the test instead of being assumed visible or hidden.

- **CONTROLS** (lit): the walk over every form in
`METADATA_FORM_REGISTRY` finds exactly two `scale` rows, `field:scale`
and `object:fields.scale`. `METADATA_FORM_REGISTRY.object` is
`objectForm`. The reader lights on a row that does name `currency`.
- **Firing case**: the `objectForm` `scale` row is not offered when
`data.type` is `currency`.
- **Dark controls**: it is still offered for `number` and `percent`, and
the door agrees on both types (`FieldSchema.safeParse` with `scale: 2`
succeeds).
- **Class guard**: no registered form offers `scale` on a currency
field.

**Old-row pins.** Nothing pinned the old row: before the fix, the
quoted-exact `git grep` above found the predicate only in
`object.form.ts` itself, and none of the tests that read `objectForm` or
`METADATA_FORM_REGISTRY` mention `scale`. There was nothing to reverse.

## Round 2, on top of `7c0a33c6ad`

- **`07c9be36bf`**: a merge of `origin/main` `c8399867b8`, run through
`scripts/pm/os-regen-merge.sh`. `registry.ts` text-merged, and
`gen:migration-registry` then wrote no change. `check:generated` read
"All 15 generated artifacts are up to date".
- Main changed none of the three reference pages this branch changes, so
the script kept the branch's bytes for them. The three reference pages
main did change (`api/package-api.mdx`, `security/permission.mdx`,
`system/translation.mdx`) are byte-identical to `origin/main` (`git
diff` empty).
  - Quoted-exact `git grep` survival checks:
- Each of the 7 semantic entries main added since the merge base appears
exactly once in both `origin/main`'s and HEAD's `registry.ts`:
`admin-scope-business-unit-blank-refused`,
`filter-equality-array-comparand-refused`,
`flow-predicate-slot-blank-string-refused`,
`package-api-contracts-unmounted-entries-retired`,
`rls-predicate-array-comparand-refused`,
`translation-per-app-settings-platform-only` and
`view-filter-rule-absent-value-refused`.
- Main's added reference line "Required and non-blank: an empty or
whitespace-only value names no business unit" appears 2 times in
`permission.mdx` on both sides.
- Main's deleted line "POST /api/v1/packages/upgrade" appears 0 times on
both sides.
- **`e2c5077421`**: the rewording to ruling 乙, across the refusal
message, the describe, the migration entry (with `registry.ts`
regenerated), the validator's comments, the `field-scale.ts` docblock,
the `schema.mdx` row, the changeset and the spec pins. What is refused
and what is accepted did not change.
- **`40ef043003`**: `gen:docs` regenerated the three reference pages. A
later `gen:schema && gen:docs` produced no further diff, and
`check:docs` read "225 generated files in sync with packages/spec".

## Tests, on HEAD `d7740c324e`

Every command went through `scripts/pm/os-verify-lock.sh`. Each count is
the runner's own summary line.

- Build: `turbo run build` over `./packages/*`, `./packages/*/*` and
`./examples/*` gave **73 successful, 73 total**.
- `@objectstack/spec`: `vitest run --project local` gave **532 files,
15667 passed, 2 todo** (round 2: 15663; the 4 new offer-side tests).
`--project repo` gave **35 files, 602 passed**.
- `field-currency-scale-refused.test.ts` alone: **12 passed** (8 from
round 2, 4 new).
- `@objectstack/spec` `typecheck` exited 0. `check:test-typecheck` held
53 files, 255 errors and 142 pinned signatures, the same as rounds 1 and
2. `check:type-check-coverage` exited 0, so the package's `tsconfig`
reaches the edited test file.
- Consumers that read the registered forms:
`@objectstack/platform-objects` translation suites gave 6 files, 137
passed; `@objectstack/lint` `validate-predicate-path-refs.test.ts` gave
1 file, 54 passed; `@objectstack/metadata-protocol`
`protocol.meta-types-*` gave 3 files, 38 passed.
- NOT re-run this round: `@objectstack/objectql` (on `40ef043003`: 309
files, 5196 passed; 1 file, 5 passed; `typecheck` exit 0 with 40 files,
234 errors and 65 pinned signatures held), `@objectstack/example-crm`,
`@objectstack/example-showcase`, and round 1's `platform-objects`,
`metadata-core`, `driver-sql` and `service-automation` suites. No file
in those packages changed since `40ef043003`. They are declared to CI.

## Reverse verification

All legs are one-shot proofs run from the committed state, each with a
trap on EXIT/INT/TERM. No rebuild was needed, because the test file
resolves `./object.form`, `./field.zod` and
`../system/metadata-form-registry` through relative `src` imports.

**Round 3, the offer.** `scripts/ablation-replace.mjs` restored the old
row, `visibleWhen: "data.type in ['number','currency','percent']"`, on
`object.form.ts`. The landing was proved on disk: new-row anchor 1 → 0,
old row 0 → 1, blob `6e87b93283` → `295afbe69f`. Then
`field-currency-scale-refused.test.ts` ran: **2 failed | 10 passed**.
The two failures are the firing case ("expected true to be false") and
the class guard (it received `object:fields.scale` where it expected
none). The lit CONTROLS, the `number` and `percent` dark controls and
all 8 round-2 tests stayed green. Restored: blob `6e87b93283` == HEAD,
`git diff HEAD` empty, and `git status --porcelain` empty.

Round 2's two legs, on `40ef043003`:

- **The ruled wording.** `field.zod.ts` was restored to its round-1 blob
`0c24baa382`, which carries the old remedy naming the other key. Main
changed nothing in this file across the merge, so that blob differs from
HEAD only by the round-2 rewording. The landing was proved by blob hash,
with the old-remedy anchor ×1 and the new one ×0. Then
`field-currency-scale-refused.test.ts` ran: **4 failed | 4 passed**. The
three message pins went red on the first-sentence assertion, and the
describe pin went red on its new clause. The firing control (every
declared value refused) and the three dark controls stayed green.
Restored: blob `d6ada97894` == HEAD, and `git diff HEAD` is empty.
- **The absence half.** `scripts/ablation-replace.mjs` appended "Move
the value to `currencyConfig.precision` instead." to the new message.
Anchor 1 → 0, blob `d6ada97894` → `017fdba420`. Result: **3 failed | 5
passed**. All three failures read "not to match
/currencyConfig|precision/", and the describe pin and every control
stayed green. Restored: blob == HEAD, and `git diff HEAD` is empty.

## Test pins of the old remedy, reversed

A repo-wide grep finds the old remedy pinned in one file only,
`packages/spec/src/data/field-currency-scale-refused.test.ts`. It had a
message `toContain` on the other key and a control named "the remedy
target parses". Here is what replaces them:

- One helper asserts the message's first sentence verbatim, the two
ruled clauses, and that the message names neither `currencyConfig` nor
`precision`. It runs on all three firing paths: the designer shape, the
shape beside a `currencyConfig` block, and the `Field.currency()` +
`ObjectSchema` path.
- The control is now "following the remedy parses". It takes each
refused shape, deletes `scale`, adds nothing, and the result is
accepted.
- The describe pin asserts the new clause and the absence of
`currencyConfig`.
- `packages/spec/src/data/currency-precision-iso4217.test.ts:293` names
that key only as `CurrencyConfigSchema`'s ISO-contradiction issue path.
It is not a remedy pin and is unchanged.

## `currencyConfig` hits that remain (`git grep` on HEAD `40ef043003`,
unchanged at `d7740c324e`)

- **In the branch's added lines** (`c8399867b8..HEAD`): 7 hits. None
names that key as a remedy or as a decimal-places carrier.
- The changeset's FROM → TO row 2 shows a field that declares a
`currencyConfig` block. Its fix is still the deletion.
- `field-zoo.object.ts:53` is in the diff only because `scale: 2` was
deleted from that line. Its `currencyConfig: { …, precision: 2 }` is
`origin/main`'s, unchanged.
- `field-currency-scale-refused.test.ts` has 5 hits: two absence
assertions, one test title, and two fixtures with a `currencyConfig`
block and no `precision` (the firing case and the remedy-follow
control). Round 3 added none.
- **In the touched files, pre-existing** (the same count on `c8399867b8`
and HEAD):
- `fields.mdx` 1, `types.mdx` 2 and the three reference pages 2 each.
The reference hits are generated from `CurrencyConfigSchema`.
  - Showcase `account.object.ts` 2, `field-zoo.object.ts` 1.
- `field.test.ts` 34 and `field.zod.ts` 10: the `CurrencyConfigSchema`
definition and tests, the ISO check, and the
`currency`-is-not-a-field-key refusal.
  - `registry.ts` 1, in another entry.
- These stay because this PR did not write them. The key's own
disposition is ruling 乙 item 3's first finding, for the `domain:spec`
seat.

## Test fixtures re-judged (round 1)

These fixtures pinned a currency field with `scale`, or `max_scale` on
currency:

- `packages/spec/src/data/field.test.ts`: "should accept **number**
field with precision and scale" spelled `type: 'currency'`. It now
spells `number`, a spelling change only.
- `packages/spec/src/data/object.test.ts`: the account fixture's
`annual_revenue` lost `scale: 2`.
- `packages/spec/src/data/field-scale.test.ts`: the resolver's "declared
scale wins" example moved from `currency` to `slider`.
- `packages/objectql/src/validation/record-validator.test.ts`: the
objectstack-ai#19320 CONTROLS loop dropped `currency`. A new objectstack-ai#19629 block holds the
three ruled pins, and its header now cites ruling 乙 for the
unconstrained write allowance.
-
`packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts`:
the objectstack-ai#11060 oracle needs a live `scale` gate, so its `total` field moved
from `currency` to `number`.
-
`packages/drivers/driver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.ts`
and `packages/metadata-core/test/injected-column-provenance.test.ts`
mirror the showcase external customer. Both lost `scale: 2`.

## Changeset

`.changeset/19629-currency-scale-retired.md` sets `@objectstack/spec:
minor` and `@objectstack/objectql: minor`, with a **BREAKING** banner.
Per AGENTS.md, `Clause-②: no (narrowing)` is breaking, and
`check-changeset-no-major` refuses `major`. Round 2 reworded it to
ruling 乙:

- Its remedy sentence and FROM → TO table now prescribe deletion only.
- Its write sentence says the allowance stays unconstrained.
- Its console bullet says both faces derive from the currency in the
console this release bundles, because of the landing order above.

Round 3 added one sentence to its `@objectstack/spec` paragraph, because
the form change is visible in Studio: "Studio's object editor no longer
offers `scale` on a currency field: the fields grid of the `objectForm`
this package registers in `METADATA_FORM_REGISTRY` now shows it only for
`number` and `percent`." It names no key to carry the value, so it holds
under ruling 乙.

It carries the ADR-0087 marker `registered
field-currency-scale-refused`. `check-adr-0087-registration` reads
"`[BREAKING+bang+clause-②-narrowing]` registered
field-currency-scale-refused". The test-only packages (`driver-sql`,
`metadata-core`, `service-automation`) ship nothing that changed, and
the examples are private.

## Acceptance notes

- `docs/qa/platform-checklist/areas/records-forms.json`: the field-zoo
fixture line still reads "f_currency scale 2". Its `knownGaps` text,
which says `scale` is not enforced on write, has been stale since objectstack-ai#7501.
This is checklist-owned text and is left for that seat.
- Four documentation YAML examples keep `precision: 18` on a currency
field (`schema.mdx` ×3, `types.mdx`). They are meant as DECIMAL(18,2)
total digits, but the pinned edit widget reads field-level `precision`
on currency as fraction digits. This predates the PR. It is the surface
of ruling 乙 item 3's second finding.
- `data-modeling/fields.mdx` ("Currency Configuration") and
`protocol/objectql/types.mdx` show `currencyConfig: { precision: 2, … }`
as a currency's configuration. This predates the PR and is the surface
of ruling 乙 item 3's first finding.
- `FieldSchema`'s alias table renames `decimals` / `decimalPlaces` to
`scale`. On a currency field that rename now leads to this refusal, and
its remedy tells the author to delete the key.
- hotcrm declares `scale: 2` on a currency quote field (hotcrm#1206,
cited by the flow oracle). That becomes a refused parse downstream, and
the migration entry prescribes the deletion.
- objectui#10221's title still says "derive the summary footer's
fraction digits from `currencyConfig.precision` / ISO minor units",
which is ruling B's wording. Under ruling 乙 only the ISO half holds.
Rewording that card belongs to the `domain:ui` seat.
- Round 3 sweep: `CurrencyConfigSchema` (`field.zod.ts:420`) accepts
`currencyConfig: { scale: … }` and renames it to
`currencyConfig.precision`, the key objectstack-ai#19992 records as read by nothing.
This predates the PR and is not `FieldSchema.scale`; it goes wherever
objectstack-ai#19992's enforce-or-remove decision takes that key.
- Round 3 sweep: the field editor form's `precision` row
(`field.form.ts:78`) is shown on currency with the help text "Decimal
places (e.g., 2 for $10.50)", while `FieldSchema.precision`'s describe
(`field.zod.ts:1211`) says "Total digits" and `objectForm`'s own
`precision` row says "Total digits". That is a `precision` row, which
this PR does not touch; it is the spec-side neighbour of
objectstack-ai/objectui#10276.
- Round 3 sweep: `numeric-column-representation.ts:49-52` records a
write-seam measurement in which a `currency` field declaring `scale: 2`
was refused. After this PR the seam no longer reads `scale` on currency,
so the docblock describes a past reading. It is an internal comment, not
an offer, and it is left alone.

## Gates, on HEAD `d7740c324e`

- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived **120** families from this worktree. Every one was
run with its exit code captured before any pipe, and **all 120 exited
0**. The `--ran` reconciliation read: "120 derived famil(ies) accounted
for — 120 run, 0 NOT-MEASURED (a DERIVED zero — all 120 recorded an exit
code and none of them is 3)". The derivation warned of a stale tree:
`origin/main` `e8f163fc3a` is 23 commits past the merge base, and 18
derivation inputs changed in that range (among them
`.github/workflows/lint.yml` and `package.json`). CI runs the PR's merge
ref with the current copies.
- `pnpm --filter @objectstack/spec check:generated`: "All 15 generated
artifacts are up to date".
- `check:i18n`: "OK (9 package(s) — all bundles in sync, no undeclared
authoring keys)". `check:nul-bytes`: OK, no raw ASCII control bytes.
- `check-changeset-no-major`: no `major` bump. The level axis reads this
PR body, so it is NOT MEASURED locally.
- CI on `d7740c324e`, read at 2026-09-24T18:25Z: 35 check-runs, 33
`success` and 2 `skipped` (`Console Pin Gate`, `Packed-tarball smoke
(opt-in)`), none failed. All seven required contexts and `Check
Changeset` read `success`.
- Lint was narrowed to the diff and was not run repo-wide. `eslint
--no-inline-config --format json` over the 25 changed `.ts`/`.mjs` files
(`c8399867b8..HEAD`, round 3 adds `object.form.ts`) reported 25 files, 0
errors, 0 warnings and no ignore notices. The narrowing holds because
`eslint.config.mjs` never enables type-aware linting (its own comment at
lines 326-328: no `parserOptions.project`, no typed rules), so this diff
cannot move any untouched file's verdict. The full `pnpm lint` belongs
to CI.


---
_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
… dataset-*, hook-* and metadata-* migration entries states each lesson in words, not tracker numbers (stage 5) (objectstack-ai#20522)

Part of objectstack-ai#20233
Stage 5: the field-, export-, api-, dataset-, hook- and metadata-
families.

Clause-②: no

**Stage 5 of a staged card.** The card stays open for later stages; this
PR carries no closing keyword. Text only: no entry id, `from` / `to`,
conversion or matching logic moves, and the chain rewrites exactly what
it rewrote before. One `surface` moves, under ruling A of the stage-1
ACCEPT (`5858839916`): it carried two tracker numbers.

## 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 is
still a tracker number.

This stage covers the next six families, `field-`, `export-`, `api-`,
`dataset-`, `hook-` and `metadata-`: **110 sites → 0** in the three
prose fields and **2 → 0** in `surface`, across 25 entry files. 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 seventeen families.

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

**Instrument.** Stage 4's TypeScript-AST census, the same script: for
each entry object literal under
`packages/spec/src/migrations/entries/**` it evaluates `replacement`,
`reason`, `acceptanceCriteria` and (separately) `surface`, joining
string literals with `+`, and counts `#` followed by 4 or 5 digits at a
word boundary. On base `9e9bb464` it reads the whole tree at **571**
sites / 7 `surface` / 61 short, which is stage 4's recorded 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 objectstack-ai#20234's surface, and this PR touches none (proved below).

**Base `9e9bb464`:** `field-` 10 entries, **28** sites (5 / 20 / 3);
`export-` 3, **21** (0 / 21 / 0); `hook-` 4, **17** (0 / 17 / 0); `api-`
5, **16** (0 / 14 / 2); `metadata-` 7, **16** (1 / 15 / 0); `dataset-`
3, **12** (2 / 8 / 2), plus **2** in `surface`. **110** sites (8 / 95 /
7) in 24 of the 32 entries; 75 distinct ids (71 bare, 1 spelled
`framework#`, 3 `objectui#`). Short numbers: 14.

**After this PR:** all six families **0**, `surface` 0; the eleven
earlier families still 0; whole tree **571 → 461**, `surface` **7 → 5**,
short **61 → 50**. The PM's rough line count (126 sites, 25 files) is a
wider instrument; the AST reading is 110 in 24 files, and the 25th file
carries only short decision-batch numbers and ruling-record ids.

| entry | sites (replacement / reason / acceptanceCriteria) | surface |
short numbers |
|---|---|---|---|
| `17.api-runtime-create-withdrawn` | 9 (0 / 7 / 2) |  |  |
| `17.export-axis-opt-in` | 7 (0 / 7 / 0) |  |  |
| `17.export-field-meta-constraints-retired` | 8 (0 / 8 / 0) |  |  |
| `17.field-runtime-create-withdrawn` | 9 (0 / 6 / 3) |  |  |
| `17.hook-context-session-roles-retired` | 6 (0 / 6 / 0) |  |  |
| `17.hook-register-empty-object-target-refused` | 8 (0 / 8 / 0) |  |  |
| `18.api-assembled-entry-split` | 1 (0 / 1 / 0) |  | 1 |
| `18.api-error-retry-after-unit-in-key` | 3 (0 / 3 / 0) |  | 1 |
| `18.api-runtime-config-durations-unit-in-key` | 3 (0 / 3 / 0) |  | 1 |
| `18.dataset-filter-nested-relation-equality-array-refused-at-save` | 2
(0 / 2 / 0) | | |
| `18.dataset-measure-aggregate-field-type-refused` | 5 (1 / 2 / 2) | 2
| 2 (1 kept) |
| `18.dataset-measure-selecting-aggregate-field-type-refused` | 5 (1 / 4
/ 0) | | 3 (1 kept) |
| `18.export-job-family-retired` | 6 (0 / 6 / 0) |  | 2 |
| `18.field-currency-scale-refused` | 0 |  | 2 |
| `18.field-max-length-malformed-or-misplaced-refused` | 8 (3 / 5 / 0) |
| |
| `18.field-min-length-malformed-or-misplaced-refused` | 3 (1 / 2 / 0) |
| |
| `18.field-multiple-non-capable-type-refused` | 4 (0 / 4 / 0) |  | 1 |
| `18.field-predicate-reference-traversal-refused` | 2 (0 / 2 / 0) | | |
| `18.field-scale-precision-integer-refused` | 2 (1 / 1 / 0) | | 1
(kept) |
| `18.hook-register-undispatched-lifecycle-event-refused` | 3 (0 / 3 /
0) | | |
| `18.metadata-customization-protocol-retired` | 4 (0 / 4 / 0) |  |  |
| `18.metadata-endpoints-switch-radius-repartitioned` | 3 (0 / 3 / 0) |
| |
| `18.metadata-manager-config-cache-ttl-unit-in-key` | 3 (1 / 2 / 0) | |
|
| `18.metadata-manager-config-inert-cache-keys-retired` | 3 (0 / 3 / 0)
| | |
| `18.metadata-plugin-additional-types-retired` | 3 (0 / 3 / 0) |  |  |
| **total, 25 entries** | **110 (8 / 95 / 7)** | **2** | **14 (3 kept)**
|

The seven entries of these families that carried no number are
untouched: `api-endpoint-cache-ttl-unit-in-key`,
`field-inline-and-related-list-columns-closed`,
`field-master-detail-set-null-refused`,
`field-reference-to-spelling-retired`, `hook-timeout-unit-in-key`,
`metadata-changed-event-payload-retired`,
`metadata-item-name-grammar-enforced`.

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

For every entry file this PR changes, both versions (`9e9bb464` and the
head) are parsed and compared: every import declaration; every property
other than the three prose fields, by evaluated value (so `id`, `from` /
`to` and any matcher); every comment token in the file; and the code
skeleton, token by token with each run of joined string literals
collapsed to one. `surface` is allowed to differ only where the base
value carried a tracker id and the head value carries none. **25 files
compared, 0 with a non-prose change**; one note, the ruling-A `surface`
of `18.dataset-measure-aggregate-field-type-refused`. The instrument is
shown able to fail first: on an in-memory copy it reports DETECTED for a
mutated `id`, a mutated comment, a mutated `surface` whose base carried
no tracker id, a mutated code token and a mutated import, and stays dark
on a prose-only mutation. 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: 72 in this repository (one spelled
`framework#`, the repository's old directory name) and 3 in
`objectstack-ai/objectui`. Ids are in code spans so this body posts no
cross-references. **4 ids answer 404** on both the issues and the pulls
endpoint (re-probed with a 200 control, `14478`); those sentences are
rewritten from what `main` records, listed in Acceptance notes.

**`api-` (11 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `5488` | Maintainer, 2026-08-07: flip `api` to `allowRuntimeCreate:
false` and refuse at the write inlet (remove, not converge the read
path); re-entry only with a real consumption path. | the measurement and
the ruling were already in the sentence; the id is dropped |
| `5040` | The declarative endpoint executor project; its acceptance
step moved showcase's endpoints to the artifact route, live. | "showcase
uses the artifact route, and its declared endpoints serve live" |
| `4052` | `BatchOptions.validateOnly`, retired the same way (a runtime
verdict, no D2). | named by the key, which the sentence already carried
|
| `5279` (PR), `5189`, `5203` (PR) | The publish gate for `api` drafts;
`publishPackage` and load-time `buildEndpointIndex` running the endpoint
gate. | named by the functions the sentence already carried |
| `2657` | Studio metadata coverage: Part B asks which un-typed concepts
(`apis` among them) become registered types. | "if the Studio
metadata-coverage work promotes `apis` to a registered type WITH A REAL
CONSUMPTION PATH" |
| `5311` | The direct-active `saveMetaItem` write was a third path past
the namespace and duplicate gates; closed as subsumed by the `5488`
ruling. | "The same refusal closes the direct-active write too, which
had been a third path past the endpoint namespace and duplicate-path
gates." |
| `18576` | Maintainer, 2026-09-17, option B: narrow the `./api` entry,
rather than add a bundle-weight rule (A) or accept the weight (C). |
"Maintainer ruling of 2026-09-17, option B (narrow the entry, rather
than add a bundle-weight rule to the browser-reachability ledger or
accept the weight as it stood)" |
| `14478`, `15677` | Maintainer ruling B of 2026-09-02: a duration's
unit lives in the key name, no offender grandfathered; the `api/` stack
of that ruling. | "Maintainer ruling B of 2026-09-02 on duration-shaped
keys"; trailing ids dropped |

**`export-` (15 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `6350` | The stock reconciliation of the v17 train's breaking
changesets against the ledger, which backfilled this entry. |
"Registered (backfilled) by the stock reconciliation that compared the
breaking changesets already on the v17 release train against this
ledger" |
| `3544`, `3710` | The user-level export axis, and its extension to the
CSV attachments scheduled reports mail out. | "the export axis, and its
extension to the CSV attachments scheduled reports mail out" |
| `6148` | **404** — see Acceptance notes. | "the gate that makes a
breaking changeset state its ADR-0087 disposition" |
| `3956` (spelled `framework#`) | The import dry run skipped the
field-level validation the real write ran; the hand-copied pre-check
mirror was added to close it. | "added when the dry run was found
skipping the field-level validation the real write ran" |
| `4633`, `6532` (PR) | Maintainer, 2026-08-06, ruling D: a
validate-only protocol operation, so the dry run's prediction is the
engine's verdict; the mirror retired. | "the maintainer's 2026-08-06
ruling D (a validate-only protocol operation, so the dry run's
prediction is the engine's verdict by construction)" |
| `4484`, `5540`, `6011` | The `findStream`, `IStorageService.list` and
`actor-user-roles-to-positions` retirements. | named by their surfaces,
which the sentence already carried |
| `6536` | The eight keys left read by nothing after the mirror retired,
deferred to their own sweep. | "this is the removal the dry-run change
deliberately deferred to a sweep of its own" |
| `17158` | Maintainer, 2026-09-12, ruling A: retire the family,
`IExportService` and `ScheduleExportInput`; `ScheduleState` with it
unless a live consumer is measured. Landing route A, 2026-09-24; scope
note, 2026-09-25. | "maintainer ruling A of 2026-09-12 (…), the landing
route the maintainer ruled on 2026-09-24 (route A: …), and a scope note
the maintainer agreed on 2026-09-25" |
| `objectui#10247` | The console retires its own async-export path
first. | "objectui retires its own side of the unimplemented
async-export path first"; "which carries objectui's own retirement" |
| `19543` | Three sibling list doors declared `limit` / `cursor` and
never read them; the export-job list's door folded into this retirement.
| "one of three sibling list doors found declaring them and never
reading them" |
| `16320` | The seven cron-typed positions nothing read, retired (three
on these defs). | "once the retirement of the cron-typed positions
nothing read had deleted theirs"; "Those earlier cron-position
deletions" |

**`field-` (20 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `7893` | Maintainer, 2026-08-12: retire the runtime `field` write
channel rather than build a read path. | the measurement and the ruling
were already in the sentence; the id is dropped |
| `5488`, `4052` | The `api` and `validateOnly` withdrawals. | "the
`api` withdrawal's rationale reused (`api-runtime-create-withdrawn`)";
named by key |
| `7743`, `7894` | The field overlay lock (`NOT_OVERRIDABLE`); the
plural `/meta/fields/` spelling folded onto the singular. | "The field
overlay refusal"; the plural door named by its path |
| `8169` | The `_diagnostics` envelope asserts well-formedness only; it
has no "in effect" axis. | "(the envelope has no "in effect" axis)" |
| `11566`, `11989` (PR), `11950` | Maintainer, 2026-08-24: tighten both
halves of `maxLength` (value shape and applicable types); shipped on
17.x; registered in a follow-up. | "Maintainer ruling of 2026-08-24,
tightening both halves — the value's shape and the types the key applies
to"; "registration was deferred to a follow-up" |
| `11875` | Maintainer, 2026-08-25, option 1: the write seam enforces
`maxLength` for `signature` / `qrcode`, then both join the
bounded-string set. | "which joined once the write seam enforced a
declared bound on them" |
| `11431` | The SQL driver stops reading a malformed bound as
authoritative (the `varchar(0)` plan). | "until it was taught to stop
reading a malformed bound as authoritative" |
| `8321` | `scale` / `precision` refused as non-integer or negative —
the house pattern. | "the house pattern the `precision`/`scale` integer
refusal set" |
| `11949` | Maintainer, 2026-08-25, option B: `minLength` is
`int().min(1)`, zero refused, the `maxLength` template in full. |
"Maintainer ruling of 2026-08-25 (option B, the lower bound at 1) … the
defect pair the 2026-08-24 ruling closed for `maxLength`" |
| `17469`, `11437` | Maintainer, 2026-09-13, option 1′: `multiple: true`
refused outside the multi-capable types — the earlier `radio` rule
generalised; the driver derives its JSON column from the spec predicate.
| "Maintainer ruling of 2026-09-13, option 1′ (the earlier rule refusing
an authored `radio` with `multiple: true`, generalised)" |
| `objectui#8886`, `objectui#8937` | The console's related list shaped
its parent filter from the spec predicate; its follow-up recorded the
driver half as owed. | "the console's related list pinned the divergence
on the consumer side when it began shaping that filter from the spec
predicate, and its follow-up recorded the driver half as owed and not
filed" |
| `20078` | Triage, 2026-09-25, remedy A: refuse the traversal at
authoring with a prescription; hydrating the field level is a capability
of its own. | "Triage routed this on 2026-09-25 to remedy A: refuse the
traversal at authoring, with a prescription." |
| `18682` | A validation rule or visibility predicate reads one hop
through a lookup. | "is served, one hop deep, and stays accepted" |
| `7501` | `scale` enforced at write time: an over-scale write refused,
never rounded. | "when `scale` was made enforced at write time (an
over-scale write refused, never rounded)"; "the write-time `scale`
enforcement" |

**`hook-` (12 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `4839`, `5049` (PR) | Both `session.roles` admin exemptions removed;
the record lock and the delegation guard back on the one permission
vocabulary. | "An earlier fix removed both readers, returning the record
lock and the delegation guard to the one permission vocabulary" |
| `4579`, `4657` | The `openApi31` and `activationEvents` retirements. |
named by their surfaces, which the sentence already carried |
| `3733` | Measured: a key removed from a non-strict schema parses clean
and is silently dropped. | "as a removed field key was measured to be" |
| `5050` | This retirement's own card. | trailing id dropped |
| `4281` (PR) | An empty hook target is not "no target": closed at the
two metadata doors. | "An earlier breaking fix established that an empty
hook target is not "no target""; "that fix's headline failure mode" |
| `5928` | The `excludeObjects` face (global except named objects); it
declined to change the matcher's read in passing. | "The later
`excludeObjects` face (a hook global except for the objects it names)";
"the `excludeObjects` change declined to do it in passing" |
| `6573` | **404** — see Acceptance notes. | trailing id dropped; the
entry states the change |
| `4001` | The unknown-key strictness campaign (ADR-0078). | trailing id
dropped (ADR-0078 kept) |
| `3195` | The hook taxonomy collapsed from 18 events to the 8
dispatched; a registration guard warns on the rest. | "the change that
collapsed the hook taxonomy to the eight dispatched events made this
branch a warn" |
| `17713` | This refusal's own card. | trailing id dropped |

**`metadata-` (11 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `12057` | Maintainer, 2026-08-29: retirement adopted, re-scope
rejected (the card's ruling comment is no longer on it; `main` records
it). | "the maintainer's ruling of 2026-08-29 adopted retirement and
rejected a re-scope" |
| `13135` | **404** — see Acceptance notes. | "executed widened to the
full coupling set the fork report on that ruling measured" |
| `11513` | **404** — see Acceptance notes. | "the 2026-08-24
lock-and-clone ruling (lock the packaged base, customize a clone) left
deliberately unchartered" |
| `15542`, `15854` | Maintainer, 2026-09-06, ruled together (2 + A):
every `endpoints.*` switch gates exactly the face its name states; the
whole-store family gets its own key. | "The maintainer ruled the two
together on 2026-09-06 as one principle: …" |
| `15543` | No shipped boot path constructs a `RestServerConfig`. | the
measurement was already in the sentence; the id is dropped |
| `15624` | The outer cache keys read by nothing, retired on their own
(the owning seat's ruling). | "(the owning seat's ruling, conditioned on
the measurement below and re-taken on the merged ref)"; "retired on its
own under ADR-0049" |
| `14478` | Maintainer ruling B of 2026-09-02 on duration units. |
"Maintainer ruling B of 2026-09-02 on duration-shaped keys"; "the
duration-unit rename" |
| `8586`, `8421` | Maintainer, 2026-08-14, jointly: remove
`additionalTypes`, and refuse unknown `/meta` types by the static
registry. | "maintainer ruling of 2026-08-14: remove the key, jointly
with refusing unknown types at the `/meta` boundary by the static
registry" |
| `4212` | Four of five declared plugin lifecycle hooks were never
invoked, `onInstall` among them. | "the plugin lifecycle's `onInstall`
(a documented hook with no invocation site)" |

**`dataset-` (9 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `19889` | Ruling A of 2026-09-24: the schema door refuses what the
compile face refuses; a field spec with no `$` key stays undescended. |
"ruling A of 2026-09-24, which made the schema door refuse what the
compile face refuses, drew the line there" |
| `20080` | Triage, 2026-09-25, remedy A: refine the two analytics
carriers; remedy B (stop the analytics door descending) changes what a
nested list means. | "Triage on 2026-09-25 routed the fix to the two
analytics carriers instead, rather than stop the analytics door
descending, which would change what a nested list means" |
| `16737`, `16099` | The measured defect: `AVG()` over a datetime is an
average year on SQLite and an error on Postgres. | the measurement was
already in the sentence; the ids are dropped |
| `16353` | The aggregate × field-type table, declared in the spec. |
named by the table, which the sentence already carried |
| `16099` (in `surface` and `acceptanceCriteria`) | The widening of the
refusal to `sum` / `avg` over every field class, registered
`not-required` against this entry. | "followed in a later change";
"widened by a later change" |
| `17560` | Director ruling B of 2026-09-13: the compile door enforces
the table for every aggregate. | "Director ruling B of 2026-09-13: …";
the sibling entry named by id |
| `15768`, `16236` | `measureResultType` typing `min` / `max` over
strings and over a `formula` return type. | the sentence already states
both |
| `17513` | Closed as a duplicate with zero rulings on it. | "the card
it cited is closed as a duplicate with zero rulings on it" |

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

`COVERED_PREFIXES` gains `field-`, `export-`, `api-`, `dataset-`,
`hook-` and `metadata-` (17 prefixes; `data-` still selects neither
`datasource-` nor `dataset-`, and `api-` does not select `apimethod-`:
the match is `startsWith`). The `REWRITTEN` floor rises from **88 to
113** ids: the 25 entries this stage rewrote. The three `it` blocks are
textually unchanged. The file keeps its stage-1 name; the header lists
the seventeen covered families.

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

From committed state, HEAD `d24a253bd0`, in one lock turn, with
`scripts/ablation-replace.mjs` in wrap mode (it owns the mutation's
restore; 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 `reason` of
`hook-register-undispatched-lifecycle-event-refused`: anchor `made this
branch a warn — ` → `made this branch a warn (objectstack-ai#3195) — `. The tool read
anchor 1 → 0 and replacement 0 → 1, blob `94bc4938` → `b045dbfd`.
- **Mutate leg.** Spec build exit 0. Preflight: marker present in 4
built files. Pin: **red**, `1 failed | 2 passed` —
`hook-register-undispatched-lifecycle-event-refused: the printed
guidance cites a tracker id: expected 'objectstack-ai#3195' to be undefined`.
- **Restore.** Tool-proven: blob `94bc4938` == 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 **`d24a253bd0`**; every reading below was taken there. Every
heavy run went through `scripts/pm/os-verify-lock.sh`, with per-step
exit codes recorded separately.

- **Build:** `pnpm exec turbo run build --concurrency=2
--filter='@objectstack/cli^...'` gives `Tasks: 58 successful, 58 total`;
the ten packages outside that closure (for `check:dual-build-cjs-loads`)
give `Tasks: 68 successful, 68 total`.
- **Pin with its neighbour:** `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`).
- **CLI unit:** `test/vitest-tiers-partition.test.ts` and
`src/utils/spec-release-changes.test.ts` give `Test Files 2 passed`,
`Tests 28 passed`.
- **Spec, the whole `local` project:** `pnpm --filter @objectstack/spec
exec vitest run --project local --maxWorkers=2` gives `Test Files 573
passed (573)`, `Tests 16801 passed | 1 todo`. **The `repo` project:**
`Test Files 38 passed`, `Tests 690 passed`.
- **Typecheck:** `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 **89** families. `--ran` over
the recorded exit codes reads **89 derived, 89 run, 0 NOT-MEASURED, 0
UNRUN**, all exit 0. They include `check:doc-authoring` ("16735
customer-facing string(s) across 1168 spec sources clean"),
`check:issue-citations`, `check:migration-registry` ("registry.ts is
current (311 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` ("no
removed session.tenantId alias"), `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`. `check:dual-build-cjs-loads` first
exited 3 (`PREREQUISITE NOT MET`: ten packages had no `dist/`); after
the ten were built it exits 0, and that is the reading recorded.
- **Lint (a proven narrowing, not the repo-wide run, which is CI's):**
`eslint --no-inline-config --format json` over the 27 changed `.ts`
files reports 27 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 27
are in it (no file-ignored notice).
- 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 one commit past the base, to
`0bbe4005`: the landed `20511` (a `qa-` entry, a retired key and
`registry.ts`). A driver-free bare-clone `merge-tree --write-tree` of
`d24a253bd0` against `0bbe4005` exits 0 with no conflicted path, and the
census over that merged tree reads 0 sites in all six families (whole
tree 461), so `main` was not merged in; CI's merge ref runs the registry
gates on the merged tree.

## Acceptance notes

- **Four dead ids, rewritten from what `main` records.** Each answers
404 on both the issues and the pulls endpoint, with a 200 control
(`14478`).
- `6148`: `scripts/check-adr-0087-registration.mjs`'s header (a
declared-breaking changeset must state its ADR-0087 disposition in
writing) — the same reading stage 4 used.
- `6573`: the objectql CHANGELOG entry "`engine.registerHook` refuses an
empty `object` target and a scope whose two faces cancel out", and
`engine.ts`'s refusal docblocks. It is this entry's own change, so the
trailing id is dropped; the entry already states the change.
- `13135`: `packages/metadata/CHANGELOG.md` ("retire the paper
metadata-customization protocol with its full coupling set … re-charter
of" the `12057` ruling, "the maintainer adopted retirement … 2026-08-29
… and" it "charters the full coupling set the fork report measured").
- `11513`: ADR-0126 names it the lock-and-clone ruling of 2026-08-24
(Salesforce-style: lock the packaged base, customize a clone), and its
section 9 records the per-field overlay layer as explicitly not
chartered.
- **One ruling read from `main`, not from its card.** `12057` answers
200 but carries only its triage comment; the 2026-08-29 ruling the entry
names is recorded in `packages/metadata/CHANGELOG.md`, and the sentence
says only what that record says.
- **Short numbers, ruling-record ids and acknowledgements went too
(invisible to the regex).** Eleven decision-batch numbers (`objectstack-ai#43` ×2,
`objectstack-ai#59` ×2, `objectstack-ai#122`, `objectstack-ai#127`, `objectstack-ai#128`, `objectstack-ai#145`, `objectstack-ai#215`, `objectstack-ai#218`, `objectstack-ai#221`) and
five ruling-record comment ids (in `field-currency-scale-refused`,
`field-predicate-reference-traversal-refused` and
`dataset-filter-nested-relation-equality-array-refused-at-save`) are
numbers an author is shown and cannot follow, so each is dropped. So are
five maintainer acknowledgements (「同意,其他也同意」 in
`api-assembled-entry-split`, 「同意」 ×3 in `export-job-family-retired`,
「同意」 in `metadata-customization-protocol-retired`): they record only
that a batch was approved, and each sentence now states the ruling's
date and content instead. The two quotes that carry the lesson stay
verbatim: "both legs, table in spec" and 「`min`/`max` numeric plus
`date`/`datetime`; everything else refused」.
- **Three `Prime Directive objectstack-ai#12` / `PD objectstack-ai#12` spellings are kept.** They
name a rule in this repository's AGENTS.md, not a tracker item, like the
ADR ids; the earlier stages kept the same spellings in the `ui-`,
`plugin-` and `system-` families.
- **`surface`, per ruling A.**
`18.dataset-measure-aggregate-field-type-refused` was the only entry of
these families whose `surface` carried tracker ids (two). Its header now
names the later widening and the sibling entry in words, and the AST
comparison shows nothing else in it moved. No test or tool reads that
`surface`: outside the migration tree, the pin and the generated
projections, the id appears only in three earlier changesets' ADR-0087
disposition markers (and this PR's changeset).
- **"issue NNNN" / "PR NNNN" spellings, checked by hand.** A scan of the
six families' evaluated prose for any run of three or more digits and
for `issue` / `card` / `PR` / `batch` / `record` / `summon` / `item`
plus a number now finds only HTTP statuses, ports, byte counts,
durations, dates, commit shas, SQLSTATE and TS error codes, ADR ids and
example values.
- **No test pinned a removed tracker number of these entries.** A search
of test files for the 25 entry ids finds only the pin,
`export-job-family-retirement.test.ts` (it asserts `not a D2 conversion`
and the backtick-free `surface`, both unchanged) and comment lines; a
search for the 75 cited numbers in `toMatch` / `toContain` assertions
finds only unrelated digit runs and runtime strings outside this card
(`api-endpoint-step.test.ts` and `endpoint-executor.test.ts` assert a
`5040` hint, which is objectstack-ai#20513's surface).
- **No open PR touches these six families.** Read twice: at the start of
this stage, 10 open PRs and 634 file rows; again just before opening
this one, 13 open PRs and 633 rows (the Version Packages PR `17076`
included both times). None carries a
`migrations/entries/semantic/NN.(field|export|api|dataset|hook|metadata)-*`
file. PR `20512` adds a retired-key file
`18.api__RestApiConfig__documentation.version.ts`; a retired key has no
id and is not in `step.semantic`, so the widened `api-` prefix does not
select it. PRs `20512`, `20504`, `20460` and `20458` add entries in
other families (`rest-`, `turso-`, `stack-`, `cube-`) and regenerate
`registry.ts`: ordinary concurrency, regenerate on merge.
- **Generated projections** (`spec-changes.json`,
`docs/protocol-upgrade-guide.md`) are regenerated, as in stages 1–4;
only the protocol-17 entries appear in them.
- **What later stages pick up** (whole tree at this head, same
instrument): **461** prose-field sites in the other families, **50**
short numbers, **5** `surface` sites.

## Line budget

Entry files: **265 changed lines** (+147 / −118) across 25 files,
against the stage-1 ≈400 budget. The whole diff is **631 lines** (+372 /
−259) in 30 files. Of the rest, `registry.ts` is 265, the two
projections are 44 (`spec-changes.json` 24, the upgrade guide 20), the
widened pin is 29 and the changeset 28.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…-*, package-*, object-*, sharing-*, audit-*, flow-* and http-* migration entries states each lesson in words, not tracker numbers (stage 6) (objectstack-ai#20536)

Part of objectstack-ai#20233
Stage 6: the rest-, analytics-, view-, package-, object-, sharing-,
audit-, flow- and http- families.

Clause-②: no

**Stage 6 of a staged card.** The card stays open for later stages; this
PR carries no closing keyword. Text only: no entry id, `from` / `to`,
conversion or matching logic moves, and the chain rewrites exactly what
it rewrote before. One `surface` moves, under ruling A of the stage-1
ACCEPT (`5858839916`): it carried two tracker numbers.

## 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 card `19123` (`5749154545`) sets the shape:
the lesson in words, and no number, dead or alive; a cross-repo number
is still a tracker number.

This stage covers the nine families `rest-`, `analytics-`, `view-`,
`package-`, `object-`, `sharing-`, `audit-`, `flow-` and `http-`: **122
sites → 0** in the three prose fields and **2 → 0** in `surface`, across
33 entry files. It also takes the two carry-overs the stage-5 record
(`5880299859`) named:

- `18.api-error-retry-after-unit-in-key`: the clause on the ~16
runtime-emitted measurements and `ApiError.retryAfter` now dates the
ruling that decided it — "its 2026-09-05 population ruling" — instead of
reading under ruling B's 2026-09-02 date alone.
- `18.inline-grid-column-currency-scale-refused`: the two ruling-record
ids and the batch / item numbers are replaced by the rulings' dates and
options, the same rewrite stage 5 gave its `field-` sibling.

Each site now says what the cited ruling, measurement or fix decided.
ADR ids stay, and so does `Prime Directive objectstack-ai#10` in
`18.package-manifest-version-grammar-enforced` (a rule in AGENTS.md, as
stages 2–5 kept `objectstack-ai#10` / `objectstack-ai#12`). `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 twenty-seven families.

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

**Instrument.** The stage-4 / stage-5 TypeScript-AST census, the same
script: for each entry object literal under
`packages/spec/src/migrations/entries/**` it evaluates `replacement`,
`reason`, `acceptanceCriteria` and (separately) `surface`, joining
string literals with `+`, and counts `#` followed by 4 or 5 digits at a
word boundary. On base `fb386074` it reads the whole tree at **461**
sites / 5 `surface` / 50 short, which is the stage-5 record's
after-count. Unevaluable fields: 0. 313 semantic entries.

**Controls, same run.**
- **Lit:** `17.aggregation-node-distinct-retired.ts` reads 7 sites
(replacement 1, reason 6), before and after.
- **Dark (comment lines):** 832 `//` / docblock lines in entry files
carry a tracker id, and none is counted; 832 before and after. Comment
lines are the sibling card's surface (the one that owns every comment
and docblock line), and this PR touches none (proved below).

**Base `fb386074`:** `rest-` 6 entries, **17** sites (0 / 17 / 0);
`view-` 10, **15** (0 / 13 / 2); `analytics-` 6, **14** (2 / 12 / 0);
`audit-` 2, **14** (2 / 12 / 0); `sharing-` 2, **14** (1 / 13 / 0);
`http-` 2, **13** (0 / 13 / 0); `object-` 7, **13** (1 / 11 / 1);
`flow-` 6, **11** (0 / 11 / 0) plus **2** in `surface`; `package-` 6,
**11** (0 / 10 / 1). **122** sites (6 / 112 / 4) in 31 of the 47
entries; 91 distinct ids read (82 in this repository, 8 in objectui, 1
`hotcrm#`), plus five ruling-record comment ids. Short numbers in the
nine families: 9 (one kept, the Prime Directive).

**After this PR:** all nine families **0**, `surface` 0; the seventeen
earlier families still 0; whole tree **461 → 339**, `surface` **5 → 3**,
short **50 → 40** (the two `inline-` batch numbers included). The PM's
rough line count (about 133 sites, about 30 files) is a wider line
instrument; the AST reading is 122 in 31 files.

| entry | sites (replacement / reason / acceptanceCriteria) | surface |
short numbers | record ids |
|---|---|---|---|---|
| `17.analytics-query-request-envelope-retired` | 1 (0 / 1 / 0) | | | |
| `17.audit-log-action-enum-retired` | 4 (0 / 4 / 0) |  |  |  |
| `17.audit-log-action-restore-retired` | 10 (2 / 8 / 0) |  |  |  |
| `17.flow-retry-max-retries-required` | 1 (0 / 1 / 0) |  |  |  |
| `17.http-request-errors-total-retired` | 7 (0 / 7 / 0) |  |  |  |
| `17.http-server-runtime-vocabulary-retired` | 6 (0 / 6 / 0) |  |  |  |
| `17.package-uninstall-explicit-all-tenants` | 3 (0 / 2 / 1) | | 1 | |
| `17.rest-server-openapi31-block-removed` | 2 (0 / 2 / 0) |  |  |  |
| `17.sharing-execution-context-retired` | 11 (1 / 10 / 0) |  |  |  |
| `17.sharing-rule-recipient-reconcile` | 3 (0 / 3 / 0) |  |  |  |
| `17.view-filter-rule-value-shaped-by-operator` | 4 (0 / 3 / 1) | | | |
| `17.view-management-protocol-retired` | 3 (0 / 2 / 1) |  |  |  |
| `18.analytics-authorable-unknown-keys-refused` | 3 (0 / 3 / 0) | | | |
| `18.analytics-date-range-array-two-bounds-required` | 7 (2 / 5 / 0) |
| 1 | |
| `18.analytics-time-dimension-date-range-vocabulary-closed` | 3 (0 / 3
/ 0) | | 1 | |
| `18.api-error-retry-after-unit-in-key` | 0 |  |  |  |
| `18.flow-decision-branch-expression-absent-refused` | 1 (0 / 1 / 0) |
| | |
| `18.flow-decision-edge-branching-first-match` | 1 (0 / 1 / 0) | | | |
| `18.flow-edge-condition-evaluated-slot-source-required` | 4 (0 / 4 /
0) | 2 | | 1 |
| `18.flow-predicate-slot-blank-string-refused` | 4 (0 / 4 / 0) | | | 1
|
| `18.inline-grid-column-currency-scale-refused` | 0 |  | 2 | 2 |
| `18.object-block-sort-item-array` | 3 (0 / 3 / 0) |  | 1 |  |
| `18.object-grid-data-view-data-converged` | 4 (0 / 3 / 1) |  |  |  |
| `18.object-grid-default-filters-rule-array` | 2 (0 / 2 / 0) |  |  |  |
| `18.object-index-unknown-keys-refused` | 4 (1 / 3 / 0) |  |  |  |
| `18.package-api-contracts-unmounted-entries-retired` | 3 (0 / 3 / 0) |
| 1 | |
| `18.package-install-request-unknown-keys-refused` | 0 |  | 1 | 1 |
| `18.package-rollback-response-retired` | 5 (0 / 5 / 0) |  |  |  |
| `18.rest-api-endpoint-handler-status-retired` | 7 (0 / 7 / 0) | | 1 |
|
| `18.rest-api-plugin-durations-unit-in-key` | 3 (0 / 3 / 0) |  | 1 |  |
| `18.rest-server-config-dead-keys-retired` | 5 (0 / 5 / 0) |  |  |  |
| `18.view-filter-rule-absent-value-refused` | 2 (0 / 2 / 0) |  |  |  |
| `18.view-filter-rule-scalar-operator-array-refused` | 4 (0 / 4 / 0) |
| | |
| `18.view-overlay-options-bag-judged` | 0 |  |  |  |
| `18.view-pagination-page-size-default-50` | 2 (0 / 2 / 0) |  |  |  |
| **total, 35 entries** | **122 (6 / 112 / 4)** | **2** | **10 (0
kept)** | **5** |

The fourteen entries of these families that carried no number are
untouched: `analytics-cube-public-default-visible-enforced`,
`analytics-query-request-format-retired`,
`flow-node-config-required-keys-refused`,
`object-grid-default-sort-retired`, `object-kanban-quick-add-retired`,
`object-tenancy-organization-field-retired`,
`package-manifest-version-grammar-enforced` (its one short number is the
kept Prime Directive), `package-version-row-semver-2-0-0`,
`rest-api-config-dead-keys-retired`,
`rest-api-documentation-version-retired` (the new entry from the landed
`rest-` change: its prose was read and is clean),
`view-filter-rule-operator-input-canonical`,
`view-item-owner-hidden-retired`, `view-overlay-judged-by-viewkind-arm`,
`view-overlay-owner-hidden-retired`. Four of the 35 changed entries
carried no four- or five-digit id: `view-overlay-options-bag-judged` (a
provenance-only acknowledgement quote),
`package-install-request-unknown-keys-refused` (a batch number and a
ruling-record id) and the two carry-overs.

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

For every entry file this PR changes, both versions (`fb386074` and the
head) are parsed and compared: every import declaration; every property
other than the three prose fields, by evaluated value (so `id`, `from` /
`to` and any matcher); every comment token in the file; and the code
skeleton, token by token with each run of joined string literals
collapsed to one. `surface` is allowed to differ only where the base
value carried a tracker id and the head value carries none. **35 files
compared, 0 with a non-prose change**; one note, the ruling-A `surface`
of `18.flow-edge-condition-evaluated-slot-source-required`. The
instrument is shown able to fail first: on an in-memory copy it reports
DETECTED for a mutated `id`, a mutated comment, a mutated `surface`
whose base carried no tracker id, a mutated code token and a mutated
import, and stays dark on a prose-only mutation. So none of the sibling
card's comment lines moved, and no entry's identity or matching moved.

`registry.ts`, compared the same way: comment tokens, imports and code
skeleton identical; all 1,572 non-prose properties identical by value
(container literals compared through their children); exactly the 35
changed ids differ; and all 313 head registry entries equal the head
entry files on `surface` / `replacement` / `reason` /
`acceptanceCriteria`.

## 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: 82 in this repository and 8 in
`objectstack-ai/objectui` (one bare id resolves there: the sort ruling's
consumer change `8758` is "objectui PR" in its sentence). The five
ruling-record comment ids were read by id. `hotcrm#1555` answers 403 to
this session and is rewritten from what `main` records. Ids are in code
spans so this body posts no cross-references. **8 of this repository's
ids answer 404** on the issues endpoint and again on the pulls endpoint
(`6206`, `6239`, `6511`, `6523`, `10004`, `14369`, `14691`, `17124`;
control `6209` answers 200); their sentences are rewritten from what
`main` records, listed in Acceptance notes.

**`rest-` (14 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `3197` | An audit: several event / subscription / connector webhook
enums are schema-only, declared with no runtime consumer. | "the
declared-but-unconsumed shape an earlier audit found in the connector
webhook and event enums one layer up" |
| `4579` | This retirement's own card (`openApi31` declared, never
enforced). | trailing id dropped; "(the `openApi31` precedent)" |
| `13823` | Maintainer, 2026-09-01: remove `handlerStatus` with a
tombstone; enforce excluded; the class direction recorded for two
sibling cards. | "maintainer ruling of 2026-09-01 on this key: remove it
with a tombstone; enforce excluded"; trailing id dropped |
| `5384` | `ApiEndpointSchema` was still an open object after `api`
became a registered metadata type; closed strictly. | "the same endpoint
vocabulary whose ApiEndpointSchema had already been closed strictly once
`api` became a registered metadata type" |
| `13808` (PR) | A factual sweep of the automation skill; one corrected
sentence taught `handlerStatus` as working machinery. | "this finding
came out of correcting that skill sentence, in a factual sweep of the
automation skill" |
| `3950` (PR) | The precedent that an exported value schema with no
consumer reads as a capability, so it leaves with its key. | "an
exported value schema with no consumer reads as a capability, so it
leaves with its key" |
| `13612`, `13613` | The unbound branded identifier schemas;
`EventNameSchema`'s binding schemas with no runtime consumer. | "two
sibling ADR-0049 findings (the unbound branded identifier schemas and
the event-name schema no runtime reads; not ruled by it)" |
| `14478`, `15677` | Maintainer ruling B on duration units (2026-09-02),
its population widened 2026-09-05; the `api/` stack of that ruling. |
the stage-3 wording, naming both dates; trailing ids dropped |
| `14369` | **404** — see Acceptance notes. | "The liveness census that
enrolled the four `RestServerConfig` sub-objects" |
| `11984` | `normalizeConfig` cast the four sub-objects instead of
parsing them; it parses them since. | "(which parses them, rather than
casting them, since an earlier fix)" |
| `14796` | A closed-set sweep of the cloud repository for the 15 dead
keys: zero hits. | "A closed-set sweep of the cloud repository at
9b6abe0f2fd5: zero hits" |
| `14691` | **404** — this retirement's own card. | trailing id dropped
|

**`analytics-` (10 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `3891` | The degraded analytics shim (the fallback serving
`/analytics/query` with no analytics service installed) dropped the
caller's identity and the contract's `where` filter at its door. | "the
retired degraded analytics shim (the fallback that answered
/analytics/query when no analytics service was installed, and dropped
the caller's identity and its `where` filter at the door)" |
| `4001` | The unknown-key strictness campaign: silent stripping of
undeclared keys ends as the default, schema family by schema family. |
"The unknown-key strictness campaign (the sweep that ended silent
stripping of undeclared keys as the default, one schema family at a
time), its data/ batch" |
| `3878` | One URL, two request bodies (the shim's envelope vs the bare
query); the envelope dialect was retired. | "strict since the degraded
shim's envelope dialect was retired (one URL, one request body)" |
| `10414` | `MetricSchema.filters` was authorable with zero consumers;
removed. | "removed later in this major, because nothing ever read it:
`metric-filters-removed`" |
| `17598` | Maintainer ruling A, 2026-09-12, re-affirmed 2026-09-13: the
array arm refuses anything but exactly two string bounds. | "Maintainer
ruling A of 2026-09-12, re-affirmed 2026-09-13, which tightened the
array arm to exactly two string bounds" |
| `16322` | The driver half of the closed preset vocabulary; its
migration table is the vocabulary entry's, unchanged (single day as the
same date twice). | "the shipped migration table for the closed preset
vocabulary"; "in a driver change of their own" |
| `17593` (PR) | All four analytics faces read the array arm through one
rule and refuse the rest with the ADR-0112 envelope. | "since the fix
that made them read the array arm one way"; "The fix that followed made
all four faces refuse it"; "Since that fix" |
| `17124` | **404** — see Acceptance notes. | "a measurement of one
authored document on each face found what that bought" |
| `16041` | Maintainer, 2026-09-06, option A (contract first): the
string arm closes to the declared preset vocabulary. | "Maintainer
ruling of 2026-09-06 on the analytics date-range string (option A —
contract first)" |
| `4614` | The preset list, once three copies (spec, objectui, docs),
became one source of truth. | "since the dashboard date filter's three
copies of the list were folded into it" |

**`view-` (10 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `5869`, `6209` (PR) | A scalar comparand on `in` / `not_in` answered
500; now a named 400 INVALID_FILTER. | "An earlier fix closed the
RUNTIME half"; the trailing `(5869)` in `acceptanceCriteria` dropped |
| `5685` | The ordering operators' comparand was widened to the strings
the platform itself produces (a schema stricter than the runtime was the
wrong side). | "an earlier fix already settled the opposite error (the
ordering operators' comparand widened to the strings the platform itself
produces)" |
| `5948` | The issue asking what `GET /ui/view/:object/:type` answers,
and its 2026-08-07 ruling, both read `GetViewResponseSchema`. | "The
issue asking what `GET /ui/view/:object/:type` answers AND its
2026-08-07 maintainer ruling"; "the shapes that ruling meant" |
| `6239` | **404** — this removal's own change (recorded in the client
and spec CHANGELOGs). | trailing id dropped |
| `19751`, `19514` | The absent-value and scalar-array findings (this
and its sibling entry's own cards). | leading ids dropped |
| `6227` | `ViewFilterRuleSchema.value` shaped by its operator (the
`view-filter-rule-value-shaped-by-operator` entry). | "since the value
was first shaped by its operator"; "from then until this change" |
| `objectui#9050` | Maintainer ruling C′, 2026-09-20: the protocol is
the only refusal set; render time never throws on a protocol-valid
document. | "the maintainer's ruling C-prime of 2026-09-20 on objectui's
render-time filter converter — the protocol is the only refusal set, so
a document it accepts never throws at render time"; the lesson quote
「the differences are the protocol's to close」 kept verbatim |
| `objectui#9853` | Maintainer, 2026-09-24: the display default page
size is 50, declared once in the protocol; objectui reads the spec
default and never hardcodes it. | "the maintainer's ruling of 2026-09-24
set the platform display page size to 50, declared once in the
protocol"; "(an earlier ruling on the grid's page size, which the
page-size ruling restated)" |
| (no id) `view-overlay-options-bag-judged` | Maintainer, 2026-09-24
(objectui's overlay-options card), option A: judge each `options.KIND`
at the door. | 「其他同意」 replaced by "the maintainer's ruling of
2026-09-24" |

**`package-` (8 ids and one ruling record)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `7705`, `7780` | Uninstall left orphaned rows (repaired); measured
there: an org-less uninstall deleted every organization's rows. |
"measured at 5 of 5 deleted, including a foreign org's, while
uninstall's orphaned-row defect was being repaired"; "exactly as the
orphaned-row repair left it" |
| `objectstack-ai#12` (short) | Not a tracker id: `rest-requireauth-default-flip`
lived in protocol 12. | "(protocol 12)", the spelling four sibling
entries use |
| `19116` | Maintainer, 2026-09-23, option A: retire the three
contract-map entries naming paths nothing mounts. | "Maintainer ruling
of 2026-09-23 (option A: retire the three contract-map entries that name
paths nothing mounts)" |
| `18604` | The measurement on one `HttpDispatcher` over a real
`SchemaRegistry`. | the measurement was already in the sentence; the id
is dropped |
| `18058` | `installPackage` rebound onto the serving `POST
/api/v1/packages`. | "rebound by an earlier fix onto the serving POST
/api/v1/packages" |
| `5856869656` (record) | Maintainer, 2026-09-27, option A: the wrapped
install form refuses an unknown top-level key by name. | "the
maintainer's ruling of 2026-09-27, option A: the wrapped form refuses an
unknown top-level key by name" |
| `12038` | Maintainer, 2026-08-27, sub-question 3A: retire the false
rollback declaration first, then author the true one. | "Maintainer
ruling of 2026-08-27 on the client SDK's unbound response contracts,
sub-question 3A: retire this false declaration first, then author the
true one"; "the ruling's own survey" |
| `11925` | Typed the SDK's un-annotated return values, keeping
compile-time guards against a wrong-contract substitution. | "the change
that typed the SDK's un-annotated return values left a compile-time
guard"; "that negative guard" |
| `3877` | Response bodies are never checked against the schemas that
declare them. | "the hazard of response bodies never checked against the
schemas that declare them, realised in the opposite direction" |

**`object-` (11 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `objectui#8221` | Maintainer, 2026-09-07, option B: the legacy string
`sort` clause retired, one spelling (the array); its fourth item routes
the `ComponentPropsMap` pull-back here. | "the maintainer's ruling of
2026-09-07 (option B) retired the legacy string `sort` clause"; "One
item of that ruling"; the item's quote kept verbatim |
| `8758` (objectui PR) | The consumer half: the string arm dropped from
`convertSortToQueryParams`. | "the objectui change that drops the string
arm from `convertSortToQueryParams`" |
| `7751` | Maintainer, 2026-08-12, direction A: the `object-*` block
family's props schemas enter `ComponentPropsMap`. | "a read-point record
from the change that brought the `object-*` blocks into
`ComponentPropsMap` (the maintainer's ruling of 2026-08-12)" |
| `objectui#6207` | Found by objectui's declared-arm parity gate: two
spec authorities disagreed on `data`'s kind. Ruled 2026-08-25, option A.
| "(contract-vs-contract, found by objectui's declared-arm parity
gate)"; "The maintainer's ruling of 2026-08-25 (option A)"; "closes the
objectui finding that the two authorities disagreed" |
| `objectui#5090` | The grid's registry declaration of `data` was
aligned to `ViewData`. | "the authority objectui aligned the grid's
registry declaration to" |
| `objectui#4648` | Its deprecated-alias carve-out: object-grid's
deprecated spellings (`staticData` among them) are not published as
authoring surface. | "the deprecated `staticData` shortcut that
objectui's deprecated-alias carve-out already refuses to publish as
authoring surface" |
| `19514`, `objectui#9050` | As in `view-`. | as in `view-` |
| `objectui#4772` | The console's index fallback editor converged onto
`IndexSchema`, dropping `where`. | "removed when objectui converged that
editor onto `IndexSchema`"; "objectui then converged that editor" |
| `4001` | As in `analytics-`. | "The unknown-key strictness campaign
held this site open" (its internal batch and site numbers dropped) |
| `5114` | The console's filter save answered 422: a strict schema
refused a key the console itself writes. | "a measured risk, the kind
that had already made a console save answer 422 (a strict schema
refusing a key the console itself writes)" |

**`sharing-` (11 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `6206` | **404** — see Acceptance notes. | "completing the
maintainer's ruling of 2026-08-07 on the share-link context (enforcement
adjudicates on the WHOLE envelope, never a per-site subset)" |
| `6430`, `6511` | The share-link enforcement path moved onto the full
context under that ruling (`6511` answers 404). | "the share-link twin,
which that same ruling moved onto the whole context" |
| `6523`, `7068` (PR) | The sharing, approval and report contracts
converged onto the full envelope (`6523` answers 404). | "the envelope
the sharing, approval and report contracts have declared since they
converged onto it"; "One change converged the contracts" |
| `7140` (PR), `7206` (PR), `7070` | The four implementations
re-annotated (sharing and audit, then approvals and reports); the split
that deferred the type's deletion. | "two more re-annotated the four
implementations (sharing and audit, then approvals and reports)"; "the
deletion that split had deferred" |
| `7218` | This retirement's own card. | trailing ids dropped |
| `1878` | The metadata property liveness audit: security properties
parsed but never enforced. | "The change came out of the metadata
property liveness audit, which found security properties parsed but
never enforced" |
| `6350` | The stock reconciliation of the v17 train's breaking
changesets, which backfilled this entry. | "registered late, by the
stock reconciliation that compared the breaking changesets already on
the v17 release train against this ledger" |

**`audit-` (8 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `7675` | Maintainer, 2026-08-12: two halves — build the cheap writers,
retire the enum values with no feature; principle recorded 「空 widget +
永远查不到东西的过滤器是可见产品缺陷;审计面宁窄勿谎」 (kept verbatim, it is the lesson). |
"Maintainer ruling 2026-08-12 on the audit log's writerless actions";
"that ruling's own survey" |
| `8144`, `8145` | The `login` / `logout` writers on the auth session
hooks; `config_change` from the settings service. | "(`login` / `logout`
on the auth session hooks, `config_change` from the settings service)" |
| `8147`, `8315` | The two retirements' own cards. | trailing ids
dropped; "(triage 2026-08-13)" kept |
| `1883`, `3146` | The undelete / purge permission lifecycle and the
soft-delete recycle bin: both open, held, not declined. | "(an undelete
/ purge permission lifecycle and a soft-delete recycle bin, neither
built yet)"; "both held open, not declined" |
| `8011` | A credential-storage audit had to re-measure two "hashed at
rest" comments; resolved by making the declaration cite its mechanism. |
"(the shape a credential-storage audit had to settle by re-measuring two
"hashed at rest" comments)" |

**`flow-` (11 ids and two ruling records)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `4247` | `maxRetries` had two defaults (schema 0, engine 3). | the
measurement was already in the sentence; the id is dropped |
| `19961` | This refusal's own card. | leading id dropped |
| `hotcrm#1555` | **403** — see Acceptance notes. | "a CRM application's
lead-conversion flow rendered a refusal screen AND ran the conversion in
one execution" |
| `17322`, `17495` (in `surface`) | The node slot rebound to the edge
door's rule at `registerFlow`, then at `objectstack validate`. | "The
node slot joined this entry with the two later changes that rebound
AutomationEngine.registerFlow and objectstack validate to the edge
door's own rule" |
| `15807`, `15430`, `15662` | The evaluated-slot rule: an `ast`-only
envelope no engine can evaluate refused; a non-string node predicate
refused at registration rather than answered a silent `false`; carried
to the edge. | "The evaluated-slot rule, carried to the edge condition —
the line that first refused an `ast`-only envelope no engine can
evaluate, and refused a non-string node predicate at registration
instead of letting the evaluator answer it a silent `false`" |
| `5550509137` (record) | 2026-09-05: an `ast`-only envelope driven
through `AutomationEngine.evaluateCondition` answers `false`. |
"(measured on 2026-09-05 by driving an `ast`-only envelope through
`AutomationEngine.evaluateCondition` directly)" |
| `17493`, `5651023407` (record) | Maintainer ruling A, 2026-09-13: the
two sibling predicate slots refuse a blank string at authoring. |
"Maintainer ruling A of 2026-09-13: the two sibling predicate slots
refuse a blank string at authoring" |
| `15572` | Its pin had held the blank admission correct because parser
and evaluator agreed. | "An earlier fix had pinned that admission as
correct" |
| `17322`, `15811` | The structural `config.condition` and every
evaluated `source` refuse a blank. | "after the structural
`config.condition` and a blank evaluated `source`" |

**`http-` (11 ids)**

| cited | what it decided (read) | how the text now carries it |
|---|---|---|
| `9650`, `9835`, `9834`, `10004` | The request counter, then the
latency histogram, moved to the transport through the response-observing
hook (`10004` answers 404). | "(the request counter, then the latency
histogram, both through the response-observing hook the transport was
given)" |
| `5122`, `3977` | The origin cards of the two named sibling entries. |
named by those entries' ids, which the sentence already carried |
| `9834`, `5295` | This retirement's own cards. | trailing ids dropped |
| `4938` | The CONFIG half of `system/http-server.zod.ts` removed. |
"The first removed the CONFIG half"; "the config half's removal in this
very file" |
| `4834`, `4988`, `5055` | The dynamic plugin-loading family, the `ui/`
interaction configs, the widget / i18n shapes — each removed by route 3.
| "the earlier removals of the dynamic plugin-loading family, the `ui/`
interaction configs and the widget / i18n shapes" |

**Beyond the four- and five-digit regex.** Nine decision-batch numbers
(`objectstack-ai#27`, `objectstack-ai#43`, `objectstack-ai#57`, `objectstack-ai#77`, `objectstack-ai#117`, `objectstack-ai#215`, `objectstack-ai#217`, `objectstack-ai#218`, `objectstack-ai#227`),
their item numbers, the campaign's internal batch and site numbers,
`batch adjudication batch 4`, 「五问一批」 and "C′ item 1" were dropped; each
sentence now carries the ruling's date and content. The provenance-only
acknowledgements 「同意」 (×2), 「其他同意」 (×2), 「217 同意」, 「其他接受」 and 「9853
默认页大小改为50」 (the ruling's content, 50, is stated in words; the quote
itself carried a tracker number) were replaced by the ruling's date and
content. The lesson-bearing quotes are kept verbatim: 「the differences
are the protocol's to close」 (×2), the sort ruling's fourth item, 「every
other operator takes a scalar」, 「lowers to a deep-equality comparand」,
「`persistViewPatch` 只存 patch,不存 merged base」 and the audit ruling's
原则记录.

## Pin — widened, not weakened

`packages/cli/test/migrate-meta-engine-guidance.test.ts`:
`COVERED_PREFIXES` 17 → **27** (`rest-`, `analytics-`, `view-`,
`package-`, `object-`, `sharing-`, `audit-`, `flow-`, `http-`, and
`inline-` for the carry-over entry, the `inline-` family's only entry,
now tracker-free); `package-` selects no `packages-` entry. `REWRITTEN`
113 → **147**: the 34 ids this stage rewrote for the first time (the 33
nine-family entries plus `inline-grid-column-currency-scale-refused`;
`api-error-retry-after-unit-in-key` was already listed). The header
comment names the new families; the three `it` blocks and every `expect`
are textually unchanged.

**Ablation, from committed HEAD `472ee29b82`, one lock turn**
(`scripts/ablation-replace.mjs` wrap mode, a restore trap by absolute
path with a blob check, `scripts/ablation-dist-preflight.mjs`):
`registry.ts`, `http-server-runtime-vocabulary-retired`'s `reason`,
anchor `behind it, vocabulary second. ADR-0049.` → `behind it,
vocabulary second. ADR-0049, objectstack-ai#5295.` (anchor 1 → 0, replacement 0 → 1,
blob `06c06741` → `702e7370`). Mutate leg: spec build exit 0; the marker
in 4 `dist` files; the pin **RED**, 1 failed | 2 passed:
`http-server-runtime-vocabulary-retired: the printed guidance cites a
tracker id: expected 'objectstack-ai#5295' to be undefined`. Restore proven by the
tool (blob == HEAD `06c06741`, `git diff HEAD` empty). Restore leg: spec
build exit 0; the marker absent from all 224 `dist` files with the tree
clean; the pin **GREEN**, 3 passed; whole tree 0 dirty paths.

No other test pins a removed number: on `main`, the tests naming any of
the 35 entry ids (`sys-audit-log-retired-actions`,
`plugin-rest-api.handler-status-retirement`,
`rest-api-config-dead-keys-retirement`,
`inline-grid-column-currency-scale-refused`,
`component-object-grid-default-filters.pin`,
`view-overlay-owner-hidden-retirement`, and two that name them in
passing) assert ids, surfaces and prescriptions this PR does not move,
not the prose it rewrites.

## Generated artifacts

- `registry.ts`: 313 semantic, 232 retired-key, 206 retired-def; exactly
the 35 ids differ (see the AST comparison above).
- `spec-changes.json` and `docs/protocol-upgrade-guide.md`: only the
protocol-17 entries appear in them, as the generators project; every
changed line is a fragment of a changed entry's field.
-
`.changeset/20233-rest-analytics-view-package-object-sharing-audit-flow-http-migration-guidance-tracker-free.md`:
`'@objectstack/spec': patch`, `Clause-②: no`.

## Verification (head `472ee29b82`)

Every heavy run through `scripts/pm/os-verify-lock.sh`, each exit code
written to disk before it was read.

- **Build:** `turbo run build --concurrency=2
--filter='@objectstack/cli^...'` → Tasks: 58 successful, 58 total
(VERDICT command-exit 0); plus the 10 packages outside that closure for
the dual-build gate → Tasks: 68 successful, 68 total.
- **Pin + neighbour:** `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` → Test Files 2 passed (2),
Tests 10 passed | 1 skipped (the default-range file's own `skipIf`).
- **CLI unit (the tests that read the registry or
`spec-changes.json`):** `spec-release-changes`, `meta.stored-flags`,
`doctor-deprecation-hint-commands`, `vitest-tiers-partition` → Test
Files 4 passed (4), Tests 48 passed (48).
- **Spec:** `--project local` → Test Files 573 passed (573), Tests 16835
passed | 1 todo; `--project repo` → Test Files 39 passed (39), Tests 701
passed (701); `src/migrations/migrations.test.ts` alone → Tests 150
passed (150).
- **Typecheck:** `pnpm --filter @objectstack/spec typecheck` exit 0
(test layer 53 files / 251 errors held); `pnpm --filter @objectstack/cli
typecheck` exit 0 (3 files / 28 errors held).
- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives 89 families over this diff; all 89
run, all exit 0; `--ran` → "89 derived, 89 run, 0 NOT-MEASURED, 0
UNRUN". Among them: `check:doc-authoring` ("16759 customer-facing
string(s) across 1172 spec sources clean"), `check:generated` ("All 15
generated artifacts are up to date"), `check:migration-registry`
("registry.ts is current (313 semantic, 232 retired-key, 206
retired-def)"), `check:spec-changes`, `check:upgrade-guide`,
`check:issue-citations` ("no issue citations added"),
`check:org-identifier`, `check:nul-bytes`, `check:api-surface`,
`check:authorable-surface`, `check:dual-build-cjs-loads` (104 require
entry points across 66 packages load), `check:type-check-debt`,
`check:adr-0087-registration`, `check:changeset-no-major`,
`check:empty-changeset`.
- **Lint, a proven narrowing:** `eslint --no-inline-config --format
json` over the 37 changed `.ts` files → 37 files, 0 errors, 0 warnings,
no file-ignored notice. Population read from `eslint.config.mjs`
(`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`);
invariance: the config enables no type-aware linting (no
`parserOptions.project`, no typed rules), so this diff cannot move any
untouched file's verdict. The full `pnpm lint` is CI's.
- **Mergeability:** `origin/main` is `1378ec7c`, three commits past the
base, none touching the migration ledger or its projections; a
driver-free bare-clone `merge-tree --write-tree` of the head against it
exits 0 with no conflicted path. `registry.ts` is shared with open PRs
`20504`, `20460` and `20458`, ordinary concurrency; no open PR touches
any of the 35 entry files or the pin.

## Acceptance notes

**404 and 403 ids, rewritten from what `main` records.**
- `6206` (the share-link ruling of 2026-08-07):
`packages/core/src/security/assemble-execution-context.ts` (the
share-link copies omitted `accessible_org_ids`; both surfaces converted
to pass the whole envelope) and
`packages/plugins/plugin-approvals/CHANGELOG.md` ("applying the ...
ruling — enforcement adjudicates on the whole envelope, never a per-site
subset").
- `6523` / `6511`: the same CHANGELOG ("converged 36 contract signatures
onto the complete `resolveAuthzContext` envelope"); `6430` (200) names
the share-link half.
- `6239`: `packages/client/CHANGELOG.md` records it as this removal
itself (retire `ViewProtocol`'s five viewId-addressed methods); dropped.
- `8758` as a bare id: its sentence names objectui, and `objectui#8758`
answers 200 (the string `sort` clause retired).
- `10004`: `packages/observability/src/semconv.ts` and the observability
CHANGELOG pair it with `9834` as the histogram's move to the transport
seam.
- `14369`: `docs/qa/platform-checklist/areas/api-backend.json` ("the
liveness census that found the block read by nothing") and the `14640`
changeset (it enrolled four of the five sub-objects). `14691`: the same
file records it as this retirement; dropped.
- `17124`: `.changeset/17124-daterange-array-arm-arity.md` records the
measurement (one authored document, four faces, three readings).
- `hotcrm#1555` (403 cross-repo):
`.changeset/15429-decision-edge-branching-first-match.md` records the
case and the node (`lead_conversion.decision_duplicate`).

**Observations, not filed.**
- Carrier: this card's later stages · 339 prose-field sites, 40 short
numbers and 3 `surface` sites remain in the other families (the largest:
`actor-` 13, `hot-` 12, `external-` 11, `query-` 11, `delete-` 10,
`stack-` 10); `stack-` and `turso-` have entries in open PRs. The pin
file keeps its stage-1 name while holding twenty-seven families.
- Carrier: the sibling card for comment and docblock lines · 832 comment
lines in entry files still cite tracker ids (unchanged), including the
header comments of `18.view-pagination-page-size-default-50.ts`,
`18.view-overlay-options-bag-judged.ts`,
`18.flow-decision-edge-branching-first-match.ts` (`hotcrm#1555`) and
`18.analytics-authorable-unknown-keys-refused.ts`.

Implemented-by: `claude/issue-20233-migrate-meta-tracker-free-stage-6` ·
`domain:spec` seat 4 dispatch, session
`session_01ARcDurZ5j34RdqsGgc4jgH`.

---
_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 — 4d31e5d3 Deployed Jan 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants