Skip to content

Validation Protocol: Cross-Field, Async, and Conditional validation - #59

Merged
huangyiirene merged 2 commits into
mainfrom
copilot/enhance-validation-protocol
Jan 21, 2026
Merged

huangyiirene merged 2 commits into
mainfrom
copilot/enhance-validation-protocol

Conversation

Copilot AI commented Jan 21, 2026 •

Copy link
Copy Markdown
Contributor

Implements comprehensive validation protocol enhancements: cross-field validation for multi-field business rules, async validation for remote checks, and conditional validation for context-aware rules.

Changes

Cross-Field Validation (17 tests)

  • Date range validation (end_date > start_date)
  • Amount comparisons with multiple fields
  • Complex expressions with AND/OR operators
  • Documented with Salesforce validation rule equivalents
{
  type: 'cross_field',
  name: 'discount_limit',
  condition: 'discount > (amount * 0.40)',
  fields: ['discount', 'amount'],
  message: 'Discount cannot exceed 40% of the amount'
}

Async Validation (14 tests)

  • Remote uniqueness checks via API endpoints
  • Timeout and debounce configuration
  • Seven documented use cases: email/username uniqueness, tax ID validation, credit card verification, address validation, domain availability, coupon validation
{
  type: 'async',
  name: 'unique_email',
  field: 'email',
  validatorUrl: '/api/users/check-email',
  debounce: 500,
  timeout: 3000
}

Conditional Validation (14 tests)

  • "Validate only if X" patterns with when/then/otherwise
  • Nested conditionals for complex business logic
  • Role-based and region-specific validation
{
  type: 'conditional',
  when: 'order_total > 10000',
  then: {
    type: 'script',
    name: 'manager_approval_required',
    condition: 'manager_approval_id = null'
  }
}

Documentation

  • Module-level docs comparing ObjectStack to Salesforce patterns
  • Inline examples for each validation type
  • Best practices for async validation (timeouts, debouncing, caching)
Original prompt

Week 3: Validation Protocol Enhancement

Day 1-2: Cross-Field Validation

Define CrossFieldValidationSchema in validation.zod.ts
Support expressions like "end_date > start_date"
Write 15+ tests
Document with Salesforce validation rule examples
Day 3-4: Async Validation

Define AsyncValidationSchema in validation.zod.ts
Support remote uniqueness checks via API
Write 10+ tests with mock endpoints
Document use cases (email uniqueness, username availability)
Day 5: Conditional Validation

Add condition field to ValidationSchema
Support "validate only if X is true" patterns
Write 10+ tests


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

@vercel

vercel Bot commented Jan 21, 2026 •

Copy link
Copy Markdown

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

Project Deployment Review Updated (UTC)
spec Error Error Jan 21, 2026 1:52pm

Request Review

…umentation

Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
@huangyiirene
huangyiirene marked this pull request as ready for review January 21, 2026 13:55
Copilot AI changed the title [WIP] Add validation protocol enhancements for cross-field and async checks Validation Protocol: Cross-Field, Async, and Conditional validation Jan 21, 2026
Copilot AI requested a review from huangyiirene January 21, 2026 13:56
@huangyiirene
huangyiirene merged commit ae00042 into main Jan 21, 2026
9 of 10 checks passed
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…tack-ai#7768) (objectstack-ai#7813)

* feat(spec): Field.number gains useGrouping presentation hint (objectstack-ai#7768)

FieldSchema gains an optional `useGrouping: boolean` (Option A, ruled
2026-08-11 on objectstack-ai#7768, maintainer veto window open) so an authored number
field can opt out of Intl.NumberFormat's digit grouping without losing
numeric semantics -- the fix for years (Field.number({ scale: 0, min: 1900 }))
rendering as "2,026" that downstream apps have worked around three times by
converting to Field.text (hotcrm-heimao#35/objectstack-ai#40/objectstack-ai#59).

No default is declared: absent defers to the renderer (interim heuristic
today, locale default eventually -- objectui#4033's contract, not this
package's). Threads through Field.number(...) automatically via the
existing FieldInput shape, same as scale/min.

Also: liveness ledger classifies the key `planned` (objectui#4033 is the
pending consumer); authorable-surface/data.json, field.mdx and
state-counts.md regenerated to match.

* chore(spec): regenerate field docs/authorable-surface/liveness after main merge

Wholesale regen (gen:docs, gen:schema's authorable-surface projection,
gen:liveness-counts) to re-materialize artifacts that drifted from commits
main picked up since this branch's last merge — the `internal` field key
(objectstack-ai#7728) and the `flows` translation surface's planned entries (objectstack-ai#7763).
check:generated: 13/13 green; check:liveness: green.

---------

Co-authored-by: Claude <noreply@anthropic.com>
zhuangjianguo pushed a commit that referenced this pull request Sep 8, 2026
…aggregate × field-type table (#16685)

Decision batch #80 (2026-09-08) holds ruling #11152 - booleans aggregate as
numbers on every backend, no per-aggregate exception - over batch #59's
blanket "every other pair refused", which never named booleans. The four
arithmetic / order rows gain the BOOLEAN_VALUE_TYPES members; the module
TSDoc and the table's pending changeset no longer publish the refusal; the
pins hold the boolean rows both literally and against AGGREGATION_CASES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 9, 2026
…AggregationFunction × FieldType) dataset measures are refused against (objectstack-ai#16353) (objectstack-ai#16684)

* feat(spec): declare the aggregate × field-type compatibility matrix (objectstack-ai#16353)

Export AGGREGATE_FIELD_TYPE_COMPATIBILITY and isAggregateCompatibleWithFieldType
from @objectstack/spec/data: the one table the dataset compiler and the lint
rule refuse dataset measures against. Rows follow the director ruling
(decision batch objectstack-ai#59), resolved against the full FieldType membership through
the field-value semantic classes; pinned literally in the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf

* chore(spec): pin the aggregate × field-type table in the api-surface and export-origins baselines (objectstack-ai#16353)

Regenerated by `check:generated --fix` after a full spec build: the two
stale shards (api-surface/data.json, export-origins/data.json) each gain the
two new exports and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf

* fix(spec): fail-closed shape guard on isAggregateCompatibleWithFieldType; correct the published grounds for the boolean and time rows (objectstack-ai#16353)

Contract-review patch round. The predicate now refuses any non-string input
(a property-key lookup alone coerced ['count'] / { toString } to a member
spelling); pinned. The TSDoc and changeset no longer claim booleans are the
divergence class - objectstack-ai#11152 has every backend answer them as numbers - and
record that row, plus the min/max refusal over the string classes (objectstack-ai#15768
types them as a supported 'string' result), as overrides of existing
opinions referred to the maintainer. The time justification names SQLite's
canonical TEXT form (objectstack-ai#3994). No row changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8SRGcf2eKTK7RRpWCGxwf

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 9, 2026
…aggregate × field-type table (objectstack-ai#16685) (objectstack-ai#16750)

* feat(spec): accept boolean / toggle for sum / avg / min / max in the aggregate × field-type table (objectstack-ai#16685)

Decision batch objectstack-ai#80 (2026-09-08) holds ruling objectstack-ai#11152 - booleans aggregate as
numbers on every backend, no per-aggregate exception - over batch objectstack-ai#59's
blanket "every other pair refused", which never named booleans. The four
arithmetic / order rows gain the BOOLEAN_VALUE_TYPES members; the module
TSDoc and the table's pending changeset no longer publish the refusal; the
pins hold the boolean rows both literally and against AGGREGATION_CASES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x

* docs(spec): retract the d.ts byte-identity claim in the boolean-members changeset; derive the flag-case vocabulary pin from AggregationFunction (objectstack-ai#16685)

Contract-review patch round. The changeset claimed dist/*.d.ts was
byte-identical; it is not - the rewritten module TSDoc ships in
dist/data/index.d.ts. It now states what holds: the exported declarations
are unchanged, the private BOOLEAN_AGGREGATE_FIELD_TYPES constant is absent
from the bundle, and api-surface / export-origins are untouched. The test
header claims only what the cross-pin reaches (the boolean axis) and the
flag-case vocabulary is derived from AggregationFunction.options instead of
a literal six-member list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x

* docs(spec): state the boolean-members constant's absence as measured — dist/*.d.ts and the bundles' export lists, not the bundle (objectstack-ai#16685)

Contract-review patch round 2. The constant does ship inside the bundles as
a non-exported binding, so "absent from the bundle" over-claimed; the
changeset now says only what was measured: absent from dist/*.d.ts and
from the bundles' export lists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016N6xmWt5hYm94ffVEwGH8x

---------

Co-authored-by: Claude <noreply@anthropic.com>
os-bill pushed a commit that referenced this pull request Sep 10, 2026
…at it

Amendment by addition, in ADR-0058's own idiom: a new blockquoted block after
Amendment II.2 recording the maintainer ruling (decision batch #59, 2026-09-06)
that per-row `previous` on a predicate write may serve a row-invariant-in-effect
rewrite, with MULTI_UPDATE_HOOK_KEY_DIVERGENCE (#14099) as the engine mechanism
that makes it safe and the two shapes the rule does not admit.

The superseded 2026-08 D3 sentence is left standing as the dated record and
carries a forward pointer to the new block, so the AGENTS.md directive-13 grep
lands on the pointer at the line that would otherwise read as the live rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
os-bill pushed a commit that referenced this pull request Sep 10, 2026
…view F1-F4)

Four prose corrections from the delta contract review's non-blocking findings.
No behaviour change, no contract change, no new argument.

F1 — ADR-0058 Amendment II.3's ruling paragraph cited the date and the decision
batch but not the recording comment. It now names comment `5560086928`, the
comment on this card that records the maintainer reply the block quotes.

F2 — round 3's rewrap left a stub line (`matched). So an`) mid-paragraph. The
paragraph is rewrapped to the block's own idiom; the prose is word-identical.

F3 — the block said it amends D3's closing SENTENCE. It amends the bullet's
last two: the "rewrite *conditioned* on the row is out of contract" sentence is
superseded for the in-place / same-key-set case alongside the "not so a rewrite
can be aimed" one. Now "closing sentences".

F4 — the changeset attributed the ruling to the director seat. The MAINTAINER
ruled; the director seat recorded it. This text ships to consumers inside the
package's CHANGELOG.md, so the misattribution was published. Now "Maintainer
ruling (recorded by the director seat, decision batch #59, 2026-09-06)".

Level re-derived rather than inherited: `packages/spec`'s files[] carries
src/**/*.zod.ts and dist, while docs/adr/** is in no package's files[]. This
round moves no published carrier and changes nothing behavioural, so the
existing `@objectstack/spec: minor` stands.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…er-row previous on a predicate write, kept safe by the key-divergence refusal (objectstack-ai#17249)

* feat(spec): HookContext admits a row-invariant-in-effect rewrite by per-row previous on a predicate write

Amend the D3 clause in hook.zod.ts (and its mirror in
bulk-write-hook-conformance.ts, plus the ADR-0058 anchor's invariant text)
so that per-row `previous` on a predicate write is supplied for a guard to
REFUSE and for a `before*` hook to make a row-invariant-in-effect rewrite —
one whose written key set is the same on every matched row — naming the
engine's MULTI_UPDATE_HOOK_KEY_DIVERGENCE (400, `keys`, `rows`) refusal as
the mechanism that makes the shape safe, and stating plainly what that
refusal looks like to an operator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH

* docs(adr-0058): record the objectstack-ai#16074 ruling as Amendment II.3, point D3 at it

Amendment by addition, in ADR-0058's own idiom: a new blockquoted block after
Amendment II.2 recording the maintainer ruling (decision batch objectstack-ai#59, 2026-09-06)
that per-row `previous` on a predicate write may serve a row-invariant-in-effect
rewrite, with MULTI_UPDATE_HOOK_KEY_DIVERGENCE (objectstack-ai#14099) as the engine mechanism
that makes it safe and the two shapes the rule does not admit.

The superseded 2026-08 D3 sentence is left standing as the dated record and
carries a forward pointer to the new block, so the AGENTS.md directive-13 grep
lands on the pointer at the line that would otherwise read as the live rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH

* docs(spec): correct two claims in the amended D3 clause (review REWORK F1/F2)

F1 — the key-divergence refusal's message does not END with "Nothing was
written". `buildMessage` (`packages/objectql/src/multi-update-hook-key-
divergence.ts`) continues "Write those records individually, from inside the
handler with 'ctx.api' or by id.", and the pin is `toContain(...)`. Say the
message SAYS the phrase and then names the remedy, in `hook.zod.ts` and in the
changeset that repeated it.

F2 — the refusal was stated unconditionally. `dispatchPerRowBeforeHooks` only
compares when `seal()` returned a key record, and `seal()` returns none when a
hook REPLACED `ctx.input.data` instead of assigning into it. So the admitted
shape is now qualified as an IN-PLACE assignment, the abstention is named where
the refusal is claimed, and a row-conditioned REPLACEMENT is listed as a third
shape the rule does not admit — it clears with no refusal at all.

Mirrored in every carrier of the same clause that this PR authored:
`bulk-write-hook-conformance.ts` (D3 docblock and its unenforced-residue note),
ADR-0058 Amendment II.3, and the anchor JSON's `invariant` print text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH

* docs(spec): name the recording comment and fix three prose claims (review F1-F4)

Four prose corrections from the delta contract review's non-blocking findings.
No behaviour change, no contract change, no new argument.

F1 — ADR-0058 Amendment II.3's ruling paragraph cited the date and the decision
batch but not the recording comment. It now names comment `5560086928`, the
comment on this card that records the maintainer reply the block quotes.

F2 — round 3's rewrap left a stub line (`matched). So an`) mid-paragraph. The
paragraph is rewrapped to the block's own idiom; the prose is word-identical.

F3 — the block said it amends D3's closing SENTENCE. It amends the bullet's
last two: the "rewrite *conditioned* on the row is out of contract" sentence is
superseded for the in-place / same-key-set case alongside the "not so a rewrite
can be aimed" one. Now "closing sentences".

F4 — the changeset attributed the ruling to the director seat. The MAINTAINER
ruled; the director seat recorded it. This text ships to consumers inside the
package's CHANGELOG.md, so the misattribution was published. Now "Maintainer
ruling (recorded by the director seat, decision batch objectstack-ai#59, 2026-09-06)".

Level re-derived rather than inherited: `packages/spec`'s files[] carries
src/**/*.zod.ts and dist, while docs/adr/** is in no package's files[]. This
round moves no published carrier and changes nothing behavioural, so the
existing `@objectstack/spec: minor` stands.

Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…ld-type table — all 74 refused pairs answer DATASET_INVALID at one compile door (objectstack-ai#17560) (objectstack-ai#18011)

Fixes objectstack-ai#17560

Executes the director-seat ruling on this card (decision batch objectstack-ai#127 item
3, comment 5651572190) in **one pass, not per field class**: `min` and
`max` are judged by `AGGREGATE_FIELD_TYPE_COMPATIBILITY` like every
other aggregate, and all 74 pairs that were
refused-by-the-table-and-enforced-by-nothing now answer
`DATASET_INVALID` / **400** at the compile door.

## What the tree said before this

Four declarations, three answers, one pair:

| declaration | `min` x `text` |
|---|---|
| `AGGREGATE_FIELD_TYPE_COMPATIBILITY` (spec, objectstack-ai#16353) | refused |
| `dataset-compiler`'s compile leg | never judged — `if
(!DERIVING_AGGREGATES.has(aggregate)) return;` |
| `measureResultType` (service-analytics, objectstack-ai#15768) | a supported
`'string'` result |
| two shipped test files, in prose | "ruled C — the table is to be
AMENDED to accept it, tracked as objectstack-ai#17513" |

The fourth row had nothing behind it: objectstack-ai#17513 is closed as a duplicate of
this card carrying zero rulings, and the one recorded ruling on this
table — decision batch objectstack-ai#59 on objectstack-ai#16099 — refuses those rows. The ruling
settled all three sub-questions together because one shared fixture
drove members of both halves.

## The ruling's Execution list, line by line

- **`dataset-compiler.ts`** — the `DERIVING_AGGREGATES` scope condition
is gone; `assertAggregateFieldTypeCompatible` judges all six aggregates
through the same `DATASET_INVALID` / 400 door. The refusal message now
names the divergence each aggregate class really has (`min`/`max` SELECT
a stored value and diverge on ORDER — collation-dependent for text,
absent altogether for `jsonb`; `sum`/`avg` DERIVE a number and diverge
on arithmetic) and prescribes accordingly. The `sum`/`avg` sentence is
byte-identical to what shipped, so objectstack-ai#16099's and objectstack-ai#16778's message pins
are untouched.
- **`measureResultType`** — asks `isAggregateCompatibleWithFieldType`
before it answers, so the rule and the table agree **by construction**
rather than by review. `STRING_SOURCE_FIELD_TYPES` and the `formula`
branch are retired; `min`/`max` over the temporal class still answers
`'time'`.
- **Tests** — the shared fixture in `measure-result-type.test.ts` is
re-aimed off refused pairs following the objectstack-ai#16737 precedent in the same
file; the **two conditional pins are FLIPPED, never deleted**
(`aggregate-nontemporal-measure-refusal.test.ts` "min / max are NOT
judged by this gate", relocated by text rather than by the ruling's line
number — see Deviations; `aggregate-datetime-measure-refusal.test.ts` "a
min over a TEXT field still compiles here"), each naming batch objectstack-ai#59 and
this ruling; the negative control that accepted pairs still compile
(`min` x `number`) is **kept** and joined by four more; the eight
`objectstack-ai#17513` citations are rewritten to this card.
- **The table's TSDoc "override" paragraph** is rewritten as settled
ground — the overridden opinion is retired, not standing beside it.
- **Breaking** — `minor` under the launch-window convention with the
BREAKING banner, plus a new ADR-0087 **semantic migration entry**
(`dataset-measure-selecting-aggregate-field-type-refused`, protocol
major 18) writing the structured TODO that names the measure and the
field type. No lossless conversion exists, which is why it is a semantic
TODO and not a D2 conversion.

## One consequence the ruling implies and did not name

Retiring the `formula` branch left `measureResultType`'s third input
(`formulaReturnType`, objectstack-ai#16236) with no reader, and
`AnalyticsServiceConfig.sourceFieldMeta`'s `returnType` key with no
consumer. Both are **removed**: a declared input nobody reads is the
declared-not-enforced shape Prime Directive objectstack-ai#10 refuses.
`FieldSchema.returnType` itself is untouched — display formatting and
validation are its other declared consumers.

## Verification

Every exit code captured **on the command** (redirect first, `EXIT=$?`,
then read), never after a pipe. All figures below are from the FINAL
tree, `035a41c7e` — the second merge of `origin/main` (`8261ff717`) that
this branch carries, built whole. They were re-taken in full on this
tree after the patch round; the pre-patch tree `399d12244` read the same
shape.

Build and tests, through `scripts/pm/os-verify-lock.sh`
(`OS_VERIFY_LOCK_SLOT=issue-17560`; four holds across both rounds,
VERDICT command-exit 0 on every one — 590s / 21s / 795s / 890s held):

```
pnpm install --frozen-lockfile                                :: exit 0
pnpm build                                     (whole repo)   :: exit 0
pnpm --filter @objectstack/spec check:generated               :: exit 0   all 15 generated artifacts up to date
pnpm --filter @objectstack/service-analytics exec vitest run  :: exit 0   111 files / 2392 tests passed
pnpm --filter @objectstack/spec test                          :: exit 0   476 files / 13556 tests passed
pnpm --filter @objectstack/service-analytics typecheck        :: exit 0
pnpm --filter @objectstack/spec typecheck                     :: exit 0
pnpm lint                                      (whole repo)   :: exit 0
```

Gate families derived from the **actual** changed paths, not from a
list: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` — 15 paths, **87 commands**, no STALE TREE
warning on this tree. All 87 re-run on `035a41c7e` after the patch
round, each recorded as `COMMAND :: exit CODE`, and reconciled:

```
dispatch-gates --ran :: exit 0
  87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED
  (a DERIVED zero — all 87 recorded an exit code and none of them is 3)
```

⚠️ On the pre-merge tree four of them answered **exit 3 — PREREQUISITE
NOT MET** (`check:doc-formula-expressions`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:type-check-debt`): each reads built output and the closure was
not built yet. That is each gate's own "nothing was measured" code,
neither a pass nor a finding. All four were re-run after the whole-repo
build and all four exit 0.

⛔ Not a complete account of what CI runs: the 48 artifact-roster
families, the 11 declared wide-population families, the 5 path-scheduled
CI jobs and the always-runs tail are each outside the derived 87, as
`dispatch-gates` prints.

### Reverse verification — direction predicted BEFORE running, and it
held

Ablation: the deleted scope condition put back as `if (aggregate !==
'sum' && aggregate !== 'avg') return;` in `dataset-compiler.ts`, on the
committed fix, under the same verify lock.

```
HEAD blob 9062405
mutated blob b2935944651c57ba3cd8ec759a3303fb33ff105c   (differs; an equal or empty hash was coded to abort)
on-disk proof   anchor occurrences 1 before / 1 after · injected marker 0 before / 1 after
                (occurrence counts on the mutated text — never an editor's exit code, never a --stat)
MUTATED  :: exit 1   4 files failed · 13 of 209 tests red
RESTORED :: exit 0   4 files passed · 209 of 209 green
restore proof   blob back to 9062405… · marker count 0 · git diff HEAD clean · WHOLE-TREE git status --porcelain empty
                (trap '<restore>' EXIT INT TERM, paths absolute from git rev-parse --show-toplevel)
```

Predicted: red in the ordinary direction — with the scope back, the
compile SUCCEEDS, so every refused-pair case fails on "expected a
refusal, none was thrown" rather than passing vacuously on an empty
result. Observed: exactly that, spread across all four files — the two
flipped pins, the re-aimed section E, and the formula end-to-end
section.

`node scripts/ablation-dist-preflight.mjs @objectstack/service-analytics
ABLATION-17560-SCOPE-RESTORED` **:: exit 1**, reported rather than
worked around: the package has no `dist/` in this tree at all. Its
prerequisite is inapplicable here rather than unmet — these suites
import their subject relatively (`../analytics-service.js`), so they
resolve to `src/`, and the 13 reds on the mutated tree beside 209 greens
on the restored one are the direct evidence that the edit reached the
subject.

## Acceptance notes — noted, not filed

- The table's module TSDoc still calls itself "the contract both
consumer legs execute — the compile-time refusal in the dataset compiler
and the authoring-time lint rule". The authoring-time leg still does not
exist; `packages/lint` never calls the predicate. Carrier: **objectstack-ai#16354**,
open and labelled `pm:blocked`, and the ruling leaves it there ("The
lint leg is unchanged"). Not filed.
- A **ninth** `objectstack-ai#17513` citation exists outside `packages/`, in the
still-pending changeset
`.changeset/deriving-aggregate-nonnumeric-field-refused.md` (objectstack-ai#16099's).
It ships in the same release as this one and its "min / max are still
not judged here" section would contradict this entry in one compiled
CHANGELOG, so it carries a superseded-within-the-same-release-window
note and its tracker pointer is repointed. Reported rather than assumed
in scope.
-
`packages/spec/src/migrations/entries/semantic/18.dataset-measure-aggregate-field-type-refused.ts`
(objectstack-ai#16778's entry) carried two scope sentences this change makes false —
"the compile leg is scoped to that class and to nothing else" and "a
measure over a field of any OTHER class is neither refused nor certified
here" — plus a pointer to objectstack-ai#16785, an issue number that does not resolve.
objectstack-ai#16099's own changeset recorded that widening it "is a `packages/spec`
edit this card is fenced out of and is reported to the `domain:spec`
seat rather than done here"; this is that seat and this card is the
carrier, so the two sentences are corrected in place and now name all
three entries.

## Patch round — the at-tier contract review's three FAIL grounds,
closed

The review (comment `5653288459`) passed the behaviour in full — one
door for all six aggregates, 74 re-derived off the raw enum, table
unamended, `formula` refused on the storage ground, both pins flipped
not deleted, controls kept, `Clause-②: no` correct — and upheld the
`returnType` removal as compelled by the ruling's own words. What failed
was the truth of the contract TEXT shipping beside it. All three fixes
are text-only; ⛔ no behaviour, pin, control or the `returnType` decision
was touched.

**T1 — a same-release changeset said the opposite, twice.**
`.changeset/16236-formula-return-type-measure-column.md` (still pending,
so it compiles into the same CHANGELOG block as this entry) promised a
typed formula measure column and a `returnType?: string` fourth member
on `sourceFieldMeta`. It now carries the same
superseded-within-the-release-window treatment already given to
objectstack-ai#16099's, at the head and again on the `sourceFieldMeta` paragraph. This
PR's own changeset now states that the removed key **was never
released** and gives the host its one line.

> Reading: released `packages/services/service-analytics/CHANGELOG.md`
(17.4.0) — `16236` 0 hits, "fourth member" 0 hits. Lit controls on the
same file: `15768` 1, `measureResultType` 2, `sourceFieldMeta` 4. Dark
control `qzwxrt4419` 0. Stronger still, the one `returnType` hit in that
released text says in as many words that the key "is not on
`AnalyticsServiceConfig.sourceFieldMeta`'s return shape". And `git grep
"fourth member" origin/main -- .changeset/16236-…` is 1 — the adding
changeset is still pending on `main`, so no tarball ever carried the
key.

**T2 — a dead tracker pointer the ruling itself named.**
`.changeset/dataset-measure-aggregate-field-type-refused.md` (objectstack-ai#16778's,
pending) still said the string rows were "under objectstack-ai#16785, ruled C — the
table itself is to be amended". Corrected where it stands: `16785`
resolves to nothing, batch objectstack-ai#127 found no ruling C behind the citation,
and the table is not amended. The file also gains the superseded banner,
and its two other now-false sentences — `sum` over a `percent` "compiles
exactly as it did before", and `avg`/`sum` over temporal being "the only
pairs whose behaviour changes in this release" — are marked where they
stand.

> Reading: `git grep 16785` over the whole repo was **1** hit, all of it
in that file — the ruling's own claim reproduced. It is now 2 in the
same file, both naming it as the retired pointer; repo-wide it appears
nowhere else. Dark control `qzwxrt4419` 0.

**T3 — the corrected objectstack-ai#16778 registry entry miscounted itself.** It
claimed to be "the FIRST of three" and pointed at "the two entries that
widened it — objectstack-ai#16099 …". objectstack-ai#16099 registered **no entry**: its changeset
declares `not-required (already-registered
dataset-measure-aggregate-field-type-refused)`, so its widening rides
this id. The `surface` now reads "ONE OF TWO", names that `not-required`
relationship explicitly, and says there is no third;
`acceptanceCriteria` keeps its one true sentence (every refused pair is
refused at the compile door at major 18) and drops the phantom entry. ⛔
Its scope criterion itself is **not** widened — that is objectstack-ai#16099's open
ask and the seat is tracking it separately. `registry.ts` regenerated.

> Reading: semantic entries whose id contains `aggregate-field-type` =
**2** (lit control: 211 entries in the directory; dark control
`qzwxrt4419` 0). No entry file is named for objectstack-ai#16099 (`git grep -l
deriving-aggregate|nonnumeric` over `entries/` exits 1). In the
regenerated `registry.ts`: "ONE OF TWO" 1, "no third entry to look for"
1, "FIRST of three" 0, "the two entries that widened it" 0 — and
repo-wide both stale phrases are 0.

⚠️ Also taken, declared rather than smuggled: the review's **F5 nit** —
the changeset's opening list read as exhaustive while naming 10 of 37
types. It now says "any of the **37** field types outside the numeric,
temporal and boolean classes — for example …", and points at the
ADR-0087 entry for the full list. One phrase, in a file T1 already
reopens.

⛔ Not done, deliberately: widening objectstack-ai#16778's `acceptanceCriteria` to
`sum`/`avg` over every class (objectstack-ai#16099's open ask, fenced out by the
dispatch), and the four items the review listed as "not this PR's to
fix".

## Landing

⛔ **Draft, and it stays that way until the seat's contract review clears
it.** The claim grades this `Clause-②: no` — nothing starts being
accepted, this pulls code back to the declared contract — but the
**path** limb of the clause-② enqueue gate fires on
`packages/spec/src/**` regardless of the declaration, so
`needs:contract-review` is carried on both the PR and the card. ⛔ Not
flipped ready, ⛔ auto-merge not armed, ⛔ not queued.


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

---------

Co-authored-by: Claude <noreply@anthropic.com>
os-elon-musk pushed a commit that referenced this pull request Sep 18, 2026
… cannot carry

The authoring-time leg of the aggregate x field-type contract (director ruling,
decision batch #59: "both legs, table in spec"). A dataset measure pairs an
`aggregate` with a `field`; `AGGREGATE_FIELD_TYPE_COMPATIBILITY` in
`@objectstack/spec` says which pairs every backend can answer identically, and
until now nothing in the authoring path read it: `avg` over a `datetime` field
validated clean, shipped, and became either a plausible wrong number (SQLite
coerces the canonical UTC text and returns the average YEAR) or a query-time
failure (PostgreSQL has no such function), decided by the deployment rather
than by the document.

`validateDatasetMeasureAggregates` walks `datasets[].measures[]`, resolves the
field's declared type on the object graph lint already indexes, and refuses the
pair when `isAggregateCompatibleWithFieldType` says no. The verdict is the
shared predicate's on every pair — no second table here — and the message names
the aggregate, the field, its declared type and the accepted set, with the way
out computed from the same table.

Silent wherever the type cannot be resolved rather than guessing: an
unresolvable base object, a dangling field path (that is
`dataset-field-unknown`'s finding), an untyped leaf, a non-string in either
position, and an aggregate outside the table's own vocabulary. It reaches
further than the compile leg in one direction only: a dotted
`relationship.field` reference, whose leaf type authoring time can read and the
compile leg's base-object field metadata cannot.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…carry (objectstack-ai#19138)

Fixes objectstack-ai#16354

Clause-②: yes (narrowing)

The lint leg of the aggregate × field-type contract (director ruling,
decision batch objectstack-ai#59, 2026-09-06, 「both legs, table in spec」). A dataset
measure pairs an `aggregate` with a `field`;
`AGGREGATE_FIELD_TYPE_COMPATIBILITY` in `@objectstack/spec` declares
which of those pairs every backend answers the same way, and nothing in
the authoring path read it. New gating rule
`measure-aggregate-field-type-refused` in
`packages/lint/src/validate-dataset-measure-aggregates.ts`, registered
in `AUTHORING_RULES`, so all three commands run it. The verdict is the
shared predicate's on every pair — `isAggregateCompatibleWithFieldType`,
the same call the compile leg makes — and there is no second table in
this repo.

## The take-order test, as an artifact

The maintainer's test for a contract-surface card: take a piece of
author-written metadata, feed it in before and after the change, and see
whether accept/reject flips. Both arms below were **run**, not reasoned
from the diff: one probe script, byte-identical in both arms, driving
the real shared registry (`runAuthoringRules('lint', …)` — the table `os
lint` / `os validate` / `os build` all run).

The sample (author-written metadata, three measures over one object):

```ts
objects: [{ name: 'crm_opportunity', sharingModel: 'private', fields: {
  name: { type: 'text' }, units: { type: 'number' },
  closed_at: { type: 'datetime' }, win_rate: { type: 'percent' },
} }],
datasets: [{ name: 'opportunity_metrics', object: 'crm_opportunity', dimensions: [], measures: [
  { name: 'avg_closed_at',  aggregate: 'avg', field: 'closed_at' },  // positive control
  { name: 'avg_units',      aggregate: 'avg', field: 'units' },      // negative control
  { name: 'total_win_rate', aggregate: 'sum', field: 'win_rate' },   // third control
] }]
```

| measure | BEFORE (`c70581bc8`, pristine worktree) | AFTER (this
branch) |
|---|---|---|
| `avg(closed_at)` — `datetime` | **ACCEPTED**, no finding |
**REFUSED**, `error` |
| `avg(units)` — `number` | ACCEPTED, no finding | ACCEPTED, no finding
(unchanged) |
| `sum(win_rate)` — `percent` | ADVISED only —
`measure-aggregate-incoherent`, `warning` | **REFUSED**, `error`, beside
that same warning |
| total findings on the sample | 1 | 3 |

⇒ accept/reject flips on author-written metadata. Contract surface.

**BEFORE arm** — taken on the pristine worktree (`git status
--porcelain` printed 0 lines) at the branch point, through the package
source, with `@objectstack/spec` freshly built in this worktree
beforehand:

```
$ pnpm exec tsx probe.mjs /home/user/wt-16354/packages/lint/src/index.ts
total findings over the whole sample: 1
--- measure[0] avg(closed_at) "avg_closed_at" -> ACCEPTED (no finding)
--- measure[1] avg(units) "avg_units"         -> ACCEPTED (no finding)
--- measure[2] sum(win_rate) "total_win_rate" -> ADVISED (warning)
      [warning] measure-aggregate-incoherent @ datasets[0].measures[2]
```

**AFTER arm** — same probe, unchanged, on this branch; run twice,
through the source and through a freshly built `dist`, with identical
verdicts:

```
$ pnpm exec tsx probe.mjs .../packages/lint/src/index.ts     # and: node probe.mjs .../packages/lint/dist/index.js
total findings over the whole sample: 3
--- measure[0] avg(closed_at) "avg_closed_at" -> REFUSED (error)
      [error] measure-aggregate-field-type-refused @ datasets[0].measures[0].aggregate
--- measure[1] avg(units) "avg_units"         -> ACCEPTED (no finding)
--- measure[2] sum(win_rate) "total_win_rate" -> REFUSED (error)
      [warning] measure-aggregate-incoherent @ datasets[0].measures[2]
      [error]   measure-aggregate-field-type-refused @ datasets[0].measures[2].aggregate
```

**Build provenance, because a stale `dist` reads exactly like a real
reading.** Both arms resolve `@objectstack/spec` through its build.
`packages/spec/dist` was absent in this fresh worktree and was built
here (`pnpm --filter '@objectstack/lint^...' build`, run twice: once
before the BEFORE arm, once after merging `main`); `packages/lint/dist`
was built for the dist arm. **Nothing was served from a turbo cache: no
`turbo` process was in either pipeline** — `pnpm --filter … build`
invokes each package's own script directly, and `grep -ciE 'cache
hit|turbo|FULL TURBO'` over the build logs returns 0. The spec `dist`
timestamp is newer than every source it was built from.

## The three controls the card names

All three are permanent tests in
`validate-dataset-measure-aggregates.test.ts` (23 tests in the file; the
whole package is 105 files / 3978 tests, green):

1. **positive control** — `avg` over a `datetime` field **fires**: one
`error` at `datasets[0].measures[0].aggregate`, message pinned to name
the aggregate, the field, its type and every accepted type.
2. **negative control** — `avg` over a `number` field is **silent**.
Generalised rather than left as one case: `avg`/`sum` over the whole
numeric class, all four arithmetic and order aggregates over the boolean
class (maintainer ruling objectstack-ai#11152), `min`/`max` over the temporal class,
and `count`/`count_distinct` over **every** declared `FieldType` are
each asserted silent.
3. **`sum` over `percent`** **fires** — the pair `analytics-service.ts`
already calls incoherent. It now carries two findings: the older
advisory about meaning (suppressible) and this gating refusal about the
contract. Deliberate, and documented in the rule's header: the two
questions disagree elsewhere — `count_distinct` × `percent` is advised
and **accepted** by the table, `avg` × `datetime` is refused here and
not advised there.

And the strongest anti-false-positive assertion, because a false
positive here is worse than the gap being closed: the rule's verdict is
compared against `isAggregateCompatibleWithFieldType` for **every
aggregate × every declared `FieldType`** (6 × 44 = 264 pairs), with
floors on *both* sides of the sweep (more than 50 refused, more than 50
accepted) so neither "stopped firing" nor "fires on everything" can
satisfy the equality vacuously.

## The message the rule emits, verbatim

```
measure "avg_closed_at" applies aggregate "avg" to field "closed_at", which object
"crm_opportunity" declares as `datetime`. That pair is refused by the aggregate ×
field-type compatibility table in @objectstack/spec, so the number a backend returns
for it is a property of the SQL dialect rather than of the data — one coerces the
stored form and answers something plausible, another has no such function and fails
at query time. "avg" accepts: number, currency, percent, rating, slider, progress,
summary, boolean, toggle.
```

hint:

```
Either point "avg" at a field of an accepted type, or aggregate "closed_at" with one
its `datetime` type accepts: count, count_distinct, min, max. `count` /
`count_distinct` accept every type because they read no arithmetic off the value; a
quantity that must be added up or averaged has to be STORED as a numeric field (a
computed column) and aggregated as one. The compile leg refuses this same pair with
`400 DATASET_INVALID` before any SQL is emitted, so this is the same repair made
earlier.
```

Both halves are **computed from the table** — the accepted set for the
refused aggregate, and the aggregates that would accept this field's
type — never prose restating it, so neither can drift from the rows.

## Where it stands down, and where it reaches further

Silent wherever the type cannot be resolved, per the spec module's own
instruction that a consumer must not hand the predicate a guess: a
dataset naming no base object or one this stack does not define, an
object with no readable field map, a field path that resolves to nothing
(that is `dataset-field-unknown`'s finding — one typo must not also
yield a type verdict), an untyped leaf, a non-string in either position,
and an aggregate outside the closed `AggregationFunction` vocabulary.
Each is a test.

It reaches further than the compile leg in exactly one direction: a
dotted `relationship.field` reference. The compile leg returns early on
those because its declared-type source answers for the base object only;
authoring time has the whole object graph, so the leaf's declared type
is a read rather than an inference, and the refusal names the object the
leaf lives on. Registry-injected columns are judged on the same axis as
authored ones.

## Verification

- `pnpm --filter @objectstack/lint test` — 105 files, **3978 passed, 0
skipped**. Re-run after merging current `main` into this branch (the
merge landed sibling work inside this package), and again after the last
commit.
- `pnpm --filter @objectstack/lint typecheck` — clean, including
`check:test-typecheck` over the test layer.
- `pnpm --filter '@objectstack/lint^...' build` and `pnpm --filter
@objectstack/lint build` — clean, dts emitted.
- Gates run locally, each exit code captured before any pipe:
`check:nul-bytes`, `check-empty-changeset --base origin/main`,
`check-adr-0087-registration --base origin/main` (judges this changeset:
`[BREAKING+clause-②-narrowing] not-required (already-registered)`),
`check-changeset-no-major`, `check:changeset-gate-self-tests`,
`check-changeset-fixed`, `check:doc-anchors`, `check-doc-frontmatter`,
`check:docs-single-h1`, `check-docs-section-name`,
`check:doc-authoring`, `check:docs-transcript-drift`,
`check:docs-spec-enumerations`, `check:docs-redirects`,
`check:docs-audit-scope`, `check:published-files`,
`check:published-readme-links`, `check:cross-package-test-inputs`,
`check:test-source-alias`, `check:type-check-coverage`,
`check-undeclared-dep-imports`, `check-comment-mask-adoption`,
`check-comment-mask-corpus`, `check-keyed-text-bounds`,
`check-section-landing-index`, `check-doc-route-spelling --advisory`,
`docs-audit/check-affected-docs`, `docs-audit/check-drift-comment`,
`check:cli-examples-parity`, `check:corpus-claim-drift`, spec
`check:docs` and `check:yaml-examples`, and the lint package's own two
doc gates. **All green.**
- **Two of those gates went red on my first pass and drove real
corrections**, both mechanical consequences of registering a rule:
`check:doc-authoring` refused a tracker id inside the new entry's
runtime `surfaceReason` string, and `check:docs-transcript-drift`
derives the author-time rule count from the registry and found four CLI
transcripts still printing the old one (45 → 46).
- ESLint, as a declared narrowing rather than a farm run: `eslint
--no-inline-config --format json` over the four touched TypeScript files
— **4 files linted, 0 errors, 0 warnings**. The invariance that makes
the narrowing a measurement rather than a skipped check is stated by the
config itself: this repo runs one `eslint.config.mjs`, which *"never
enables type-aware linting (no `parserOptions.project`, no typed
`@typescript-eslint` rules) for ANY file, test or not"* — so no verdict
on an untouched file can move on account of this diff, which changes no
config and no shared roster. The population is that config's own `files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` blocks minus their `ignores`;
the repo-wide run belongs to CI.
- `check:type-check-debt` reported **PREREQUISITE NOT MET (exit 3)** —
the whole-workspace closure is not built here, and it says in its own
words that this "is NOT a pass and NOT a finding". Recorded as not
measured, not as green. Its sibling `check:type-check-coverage` did run
and passed.
- The gate derivation (`scripts/pm/dispatch-gates.mjs --commands`) names
86 families for this change set; 26 were run locally and the rest —
repo-wide scans and populations CI owns — are left to CI, as a declared
narrowing.

## Acceptance notes

Out of scope here, filed nowhere by this PR, recorded so they are not
rediscovered:

- **The `dataset` metadata type is not gated at the runtime publish door
at all.** `dataset` is a registered type with `allowRuntimeCreate: true`
and `supportsOverlay: true`, but `TYPE_TO_STACK_KEY` in
`runtime-gate.ts` maps no `dataset` row, so a dataset write builds no
per-write snapshot and **zero** author-time rules dispatch for it —
including the existence rules whose whole failure mode is a chart that
renders empty. That is why this rule declares `surfaces: cli` with a
reason naming the type axis rather than the snapshot's contents: both
collections it reads are carried, so the usual reason does not apply.
Mapping that type is a card of its own, and it would hand the door every
rule reading `stack.datasets` at once.
- **The refusal's divergence and remedy prose lives only in
`service-analytics`.** The compile leg builds its message from two local
helpers; `@objectstack/lint` cannot import them (its dependency
direction is lint → spec, never a service), so this rule states the same
two facts in its own words. Two accounts of one refusal can drift.
Moving that prose into `@objectstack/spec` beside the table would make
both legs read one sentence.
- **One pair now yields two findings.** `sum` × `percent` is reported by
both this rule (`error`, contract) and `measure-aggregate-incoherent`
(`warning`, semantics, suppressible). Consolidating them is a decision
about a published rule id's severity and suppressibility, which is its
own PR by this repo's own convention.

**Note on the declaration — it diverges from the claim comment,
deliberately.** The claim carries `Clause-②: no`, which is right about
the refusal on its own: narrowing an accept set is a semantic surface
and does not by itself touch clause ②. But this diff *also* adds
published exports from `@objectstack/lint`
(`validateDatasetMeasureAggregates`,
`MEASURE_AGGREGATE_FIELD_TYPE_REFUSED`,
`DatasetMeasureAggregateFinding`) and an `AUTHORING_RULES` entry — both
widening tells against a `no` declaration — so the honest value is
`yes`, and `yes (narrowing)` is the spelling for a diff that widens
*and* narrows. Body and changeset carry the identical line. The
changeset additionally carries the migration per refused class and the
ADR-0087 disposition `not-required (already-registered …)`: the two
semantic entries registering this exact surface already exist, and the
compile-time leg declared the same disposition against the first of
them. The claim comment is the seat's to amend, not this PR's.

This PR opens as a draft and stays draft.

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

---------

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
…mes a known repository, so pre-#N / post-#N are judged and framework#N is this repository (objectstack-ai#20554)

Fixes objectstack-ai#20330
Clause-②: no

## What changed

`scripts/check-issue-citations.mjs` read ANY `word#N` as a repository
reference, so `pre-objectstack-ai#12248`, `post-objectstack-ai#6640`, `Pre-#N`, `POST-#N` and
`Framework#N` were classed cross-repo and never judged. Its qualifier is
now a **closed set**. A token joined to `#N` names a repository only
when it is an `owner/repo` form or a name in the new
`KNOWN_REPOSITORIES` table, matched case-insensitively. Any other prefix
is prose, and the number after it is this repository's and is judged.

One recogniser, `repositoryOf`, is asked in all three places: by
extraction, by the board's probe set (`boardWanted`) and by the
classifier (`namesThisRepository`). The three can no longer disagree
about a token.

- `KNOWN_REPOSITORIES` rows: `objectstack` and `framework` (both THIS
repository), `objectui`, `ui` (an objectui alias, joined form only),
`cloud`, `hotcrm`, `hotcrm-heimao`, `os-tianshun-mtc` and
`os-project-titanwind-ehr`. Each row carries the measurement that put it
in.
- **Prose form.** `objectui PR objectstack-ai#10264`, `objectui objectstack-ai#2670`, `cloud objectstack-ai#2937`
and `framework objectstack-ai#2679` read as their repository. The form is a known
repository name, whitespace, an optional `PR` or `issue`, then `#N`. The
alias `ui` is excluded from it, because `UI #N` is ordinary English.
- **No carry across a pair.** In `objectui#6110 + objectstack-ai#6111` the second
number stays this repository's. The convention is to qualify each
number, and the one live site in the claim's surface is respelled:
`packages/spec/src/data/field.zod.ts:370` now reads `objectui#6110 +
objectui#6111`. This is comment-only, with a `patch` changeset.
- **Ordinal heads.** Closing the set exposes ordinals that were hidden
behind a fake qualifier. `NON_CITATION_HEADS` gains `OQ` (`ADR-0076
OQ#10`, 10 sites) and `PKCS` (`PKCS#11`, 1 site). A hyphen joining a
head to its `#` is now read as the same head (`Prime-Directive-objectstack-ai#12`, 2
sites). `PD#12` (8 sites) was already covered by the existing `pd` head
once its candidate is refused.
- **Refusal text.** The `REMEDY` text now states the grammar that judged
the author.

### A false red in the same seam, fixed because this change would have
widened it

`buildBoard` probed only UNQUALIFIED numbers, so a diff adding
`objectstack#N` was classified against a board that never asked about N.
Reproduced on unmodified `288611e3e5`: I appended `objectstack#20330`
(this live card) to a swept file and ran `node
scripts/check-issue-citations.mjs`. It exited **2**, reading `board:
probed (0 citations)` and `[allocated-but-absent] ...
objectstack#20330`. Reading `framework#N` as this repository would have
inherited that false red on every site. The probe set is now
`boardWanted`, meaning every citation judged here. The self-test pins it
through `probeBoard` over a stub. The blocking rule is unchanged:
findings still exit 2.

## Measurements the design rests on

- **`framework` names this repository.** `git ls-remote
https://github.com/objectstack-ai/framework` answered HEAD `288611e3e5`,
identical to `objectstack-ai/objectstack`. The controls diverged: a
nonexistent name under the same owner exited 128, and
`objectstack-ai/objectui` answered its own HEAD `0eb9f36aca`. The REST
and web routes to `framework` answered 403 from this session's proxy
(bound to configured repositories), so git's rename redirect was the
readable instrument. `framework#N` / `Framework#N` therefore read as
THIS repository.
- **Qualifier census on `288611e3e5`, over the declared surfaces.**
There were 27 distinct candidates behind 1,655 sites:
- This repository: `framework` 255, `objectstack` 111,
`objectstack-ai/objectstack` 9, `Framework` 2.
- Siblings: `objectui` 672, `cloud` 213, `hotcrm` 24,
`objectstack-ai/objectui` 12, `ui` 11, `objectstack-ai/cloud` 9,
`better-auth/better-auth` 3, `os-tianshun-mtc` 2, and 1 each of
`hotcrm-heimao`, `os-project-titanwind-ehr`, `objectstack-ai/objectos`,
`objectstack-ai/ats` and `objectstack-ai/hotcrm`.
- Prose: `pre-` 284, `post-` 12, `Pre-` 4, `Post-` 4, `PRE-` 1 and
`POST-` 1.
  - Ordinals: `OQ` 10, `PD` 8, `Prime-Directive-` 2, `PKCS` 1.
- **`ui` is objectui.** `objectstack-ai/ui` does not exist, and
`ui#6837`, `ui#6206` and `ui#6207` are objectui's records on its board
(`objectstack#6206` answers 404, so it would have been a false death).
- **Prose form, 27 sites.** For every objectui number I read objectui's
board and this repository's. The objectui record is the one each
sentence describes: `objectui objectstack-ai#2670` is "Flow designer: render loop /
parallel / try_catch as nested", cited from `loop-node.ts`, and
`objectui PR objectstack-ai#4264` diagnoses a path on the right side of `==`, cited
beside `PATH_SHAPED_LITERAL`. This repository's same number is unrelated
on every site. The `cloud` sites could not be read (private) and follow
their context. There were zero false positives.
- **Pair carry, 48 sites. The measurement refuses a carry rule.**
- `,` and `and`: every cross-repo pair I could judge names THIS
repository's second number. `cloud#1013 and objectstack-ai#10645` is this repository's
cli `serve` issue (4 sites), `cloud#1020, objectstack-ai#5233` its org gate issue (6
sites), `objectui#2561, objectstack-ai#3021` its lazySchema PR, and `objectui#3136 and
objectstack-ai#14492` answers 404 on objectui.
- `/`: mostly carries, but not always. `objectui#3226 / objectstack-ai#4827` is this
repository's objectstack-ai#4827, a conversion entry handed over from objectui and
cited from `conversions/registry.ts`.
- `+`: exactly one distinct pair exists in the corpus, which is too thin
to establish a convention.

## Census of the newly judged spellings

Taken with the gate's own `--census --json` at `a3c14755f8` against an
enumerated board (184 pages, frontier objectstack-ai#20551). The per-site transition
comes from the gate's `--list` before and after the change. Dead means
`allocated-but-absent`. Four of the numbers (14657, 12998, 10194 and
8692) were re-probed directly and answered 404.

| spelling | sites now judged | dead |
|---|---:|---:|
| `pre-#N` | 284 | 26 |
| `framework#N` | 255 | 0 |
| `post-#N` | 12 | 0 |
| `Pre-#N` | 4 | 1 |
| `Post-#N` | 4 | 0 |
| `Framework#N` | 2 | 0 |
| `PRE-#N` | 1 | 0 |
| `POST-#N` | 1 | 0 |
| **total** | **563** | **27** |

Other readings, same run:

- **Newly deferred (25 sites):** the 24 prose-form sites plus the
respelled `field.zod.ts:370`. Four of them were base census deaths that
were never deaths: `objectui PR objectstack-ai#8758` three times, and the respelled
`objectstack-ai#6111`.
- **No longer extracted (21 ordinals):** `OQ#10` ×10, `PD#12`/`PD#10`
×8, `Prime-Directive-objectstack-ai#10`/`objectstack-ai#12` ×2 and `PKCS#11` ×1.
- **Whole-census tally:**
- Base `288611e3e5`: 38,109 judged. resolves 32,202 · resolves-as-pull
1,863 · cross-repo-unjudged 1,535 · allocated-but-absent 2,509.
- Branch `a3c14755f8`: 38,088 judged. resolves 32,689 · resolves-as-pull
1,891 · cross-repo-unjudged 976 · allocated-but-absent 2,532.
- The board moved between the two runs, so the per-site transition above
is the reading, not the tally difference. The cross-repo count
reconciles exactly: 1,535 − 563 − 21 + 25 = 976.

## Verification

All gates below ran at `a3c14755f8`, the branch head.

- **Self-test.** `node scripts/check-issue-citations.mjs --self-test`
exits 0 with 114 cases across 8 batteries (base: 73 cases across 7). The
new battery `qualifier` (floor 40) pins every spelling both ways: lit on
a live number and a FINDING on a dead one for `pre-` `post-` `Pre-`
`Post-` `PRE-` `POST-`, and for `framework` `Framework`
`objectstack-ai/framework` `objectstack`. It also pins cross-repo even
when dead for `objectui` `OBJECTUI` `ui` `cloud` `hotcrm`
`objectstack-ai/objectui` `better-auth/better-auth`, and covers:
  - an unknown word prefix read as prose;
  - the four ordinal heads;
- the prose form, lit and dead, including `PR #N` and `UI #N` NOT being
the prose form;
- the pair, no carry (dead second number red) and qualified number by
number (both deferred);
  - `boardWanted` and a probed-board resolution of `objectstack#20330`;
  - registry hygiene.
`live-corpus` gains a pin that every qualifier the live corpus keeps
names a repository.
- **Ablations.** Six mutations went through
`scripts/ablation-replace.mjs`, each landing on disk with the anchor
count 1 → 0 and the blob changed, each red on its own case, and each
restored with blob equal to HEAD `8b6cf12653dd` and `git diff HEAD`
empty:
- M1: the recogniser returns any bare candidate. The self-test reds on
"`pre-` is prose".
- M2: the probe set reverts to unqualified-only. It reds on "the board's
probe set".
- M3: the `framework` row is renamed. It reds on "`Framework` is THIS
repository".
  - M4: the hyphen head is off. It reds on "`Prime-Directive-objectstack-ai#12`".
- M5: the prose form is off. It reds on "`objectui PR #N` names
objectui".
  - M6: the `OQ` head is removed. It reds on "`ADR-0076 OQ#10`".
- **Diff-scoped verdict** (`node scripts/check-issue-citations.mjs`, as
CI runs it): exit 0 on this branch. It judged the respelled line's 2
citations, both `cross-repo-unjudged`.
- **One-time end-to-end proof** (no permanent test; injected uncommitted
and restored with blob equal to HEAD and `git diff HEAD` empty). I
appended `pre-objectstack-ai#12248` (dead) and `framework#20330` (live) to
`packages/cli/src/commands/generate.ts` and ran the diff verdict twice:
- The branch gate exits **2**. `pre-objectstack-ai#12248` is `allocated-but-absent`,
`framework#20330` resolves on a board `probed (2 citations)`, and the
respelled pair stays cross-repo.
- The `288611e3e5` gate, from a temporary copy, exits **0** with all 4
`cross-repo-unjudged`. That is the hole this closes.
- **Census** (`--census --json`): exit 0. It is report-only and never
fails.
- **Derived gates.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived 97 commands, and all 97
ran. 96 exit 0, including `pnpm check:pm-dispatch-gates`, whose
`dispatch-gates.mjs --self-test` passes 1,976 cases. That self-test pins
this file's `:204 local-env` declaration line, and every edit here stays
below it or is line-neutral. `--ran` reconciliation reads "97 derived
famil(ies) accounted for — 96 run, 1 NOT-MEASURED (1 DERIVED from a
recorded exit 3)".
- **NOT MEASURED: `check:dual-build-cjs-loads`.** Reason: it loads every
package's built CJS entry, and this box holds no dist for 80+ packages.
The diff changes no emitted code. `@objectstack/spec` was rebuilt, and
its entry gates (`check:browser-reachable-entries`,
`check:entry-nameability`) exit 0. CI's Lint and Repo Gates owns this
one.
- **Spec package.** `pnpm --filter @objectstack/spec typecheck` returned
VERDICT command-exit 0. See the report comment for the test run.

## Acceptance notes

- **Dead sites the census hands over, not rewritten here.** One is under
`packages/spec/src/**` and is input for the staged sweep on objectstack-ai#20234:
`packages/spec/src/meta-spelling/manifest-collection-spelling.ts:71`
`pre-objectstack-ai#10194`. The other 26 are outside `packages/spec/src/**`, and no
card names them:
  - `packages/cli/src/commands/generate.ts:1842` `pre-objectstack-ai#14657`
  - `packages/cli/src/utils/storage-driver.ts:206` `pre-objectstack-ai#6345`
- `packages/drivers/driver-sql/src/schema-drift.ts:2458`, `:2474`
`pre-objectstack-ai#12998`
- `packages/drivers/driver-sql/src/sql-driver.ts:3872`, `:16545`
`pre-objectstack-ai#17590`
- `packages/drivers/driver-sql/src/sql-driver.ts:18285`, `:18324`
`pre-objectstack-ai#12998`
  - `packages/drivers/driver-sql/src/sql-driver.ts:20095` `Pre-objectstack-ai#12380`
- `packages/drivers/driver-turso/src/remote-transport.ts:2624`
`pre-objectstack-ai#12380`
  - `packages/lint/src/validate-searchable-fields.ts:312` `pre-objectstack-ai#8404`
  - `packages/metadata-protocol/src/protocol.ts:2793` `pre-objectstack-ai#10888`
  - `packages/metadata-protocol/src/seed-loader.ts:1947` `pre-objectstack-ai#11674`
  - `packages/objectql/src/action-governance.ts:339` `pre-objectstack-ai#14423`
  - `packages/plugins/plugin-auth/src/auth-manager.ts:5629` `pre-objectstack-ai#14762`
-
`packages/plugins/plugin-security/src/bootstrap-platform-admin.ts:266`,
`:630` `pre-objectstack-ai#8692`
- `packages/plugins/plugin-security/src/per-organization-catalog.ts:314`
`pre-objectstack-ai#8692`
-
`packages/plugins/plugin-security/src/permission-set-projection.ts:482`
`pre-objectstack-ai#6483`
-
`packages/plugins/plugin-sharing/src/backfill-sys-record-share-organizations.ts:5`
`pre-objectstack-ai#14484`
  - `packages/runtime/src/domains/mcp.ts:364` `pre-objectstack-ai#8726`
- `packages/runtime/src/sandbox/body-runner.ts:548`, `:735` `pre-objectstack-ai#14758`
  - `packages/runtime/src/sandbox/script-runner.ts:440` `pre-objectstack-ai#14758`
  - `packages/types/src/driver-error-classification.ts:608` `pre-objectstack-ai#13324`
  - `packages/types/src/node.ts:1428` `pre-objectstack-ai#10943`
- **The same `objectui#6110 + objectstack-ai#6111` pair outside the claim's surface.**
It still reads `objectstack-ai#6111` as this repository's (404 here), so these are
existing census deaths: `packages/spec/src/ui/view.zod.ts:3634` (for the
objectstack-ai#20234 sweep),
`packages/metadata-core/src/form-predicate-root-policy.ts:14`, `:120`
and `:205`, and `packages/metadata/src/plugin.ts:910`. Each respells to
`objectui#6110 + objectui#6111`.
- **Pairs that read silently wrong, not dead.** Several `REPO#N / #M`
pairs name the qualifier's own second number, which resolves here as an
unrelated record. Examples: `hotcrm-heimao#35/objectstack-ai#40/objectstack-ai#59`,
`objectui#2715/objectstack-ai#2717`, `objectui#2711/objectstack-ai#2722`, `objectui#4648/objectstack-ai#4901`,
`objectui#5018 / objectstack-ai#6469`, `cloud#957 / objectstack-ai#962` and `cloud#930/objectstack-ai#944`. No
gate can see these, because they resolve. The convention in the refusal
text (qualify each number) is the remedy when someone next touches the
line.
- **Seat 4's `objectui PR objectstack-ai#10264` specimen.**
`packages/spec/src/api/export-job-family-retirement.test.ts:25` sits on
a DEFERRED surface (`packages/**/*.test.ts`), and `surfaceFor` answers
`null` for it. The census never judged that site. The prose form it
names is now read correctly wherever the census does look.
- **Observed once: a truncated board enumeration accepted as a
reading.** My first branch `--census` read `enumerated (126 pages)` with
frontier objectstack-ai#13977, against 184 pages and objectstack-ai#20551 on the re-run minutes
later, and reported 9,160 `never-issued` phantoms. `enumerateBoard`
stops at the first page without `rel="next"` and trusts the maximum it
saw as the frontier. This diff does not touch that code. The diff-scoped
verdict enumerates only past 400 distinct numbers. Recorded, not filed;
the seat decides.
- **Scope declaration.** `NON_CITATION_HEADS` (two rows) and
`nonCitationHead` (the hyphen) are grammar next to the qualifier, not
the qualifier itself. They are here because closing the qualifier made
those 13 ordinal sites judged citations of this repository's objectstack-ai#10, objectstack-ai#11
and objectstack-ai#12, which is false. No gate was added, and the diff-scoped blocking
rule is unchanged.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…commits that decided them (objectstack-ai#20729)

Part of objectstack-ai#20596
Clause-②: no

## What changed

This is the eighth stage of the `domain:services` lane of the
dead-citation sweep. It covers
`packages/services/service-analytics/src/**` and nothing else. By the
seat's census at the claim (`5899485578`), it is the largest package in
the lane that no in-flight work holds. Later stages cover the other
packages, so this PR says `Part of` and the card stays open.

Every comment or docblock site in scope that cited a tracker number
answering 404 has been rewritten in ruling C+D's form C (comment
5749154545 on objectstack-ai#19123), by the method of stages 1 to 7 (PR objectstack-ai#20609 as
`422db788a`, PR objectstack-ai#20626 as `b80ab579d`, PR objectstack-ai#20634 as `4d04b6be3`, PR
objectstack-ai#20658 as `9a4b2bb38`, PR objectstack-ai#20693 as `0e9ad74fb`, PR objectstack-ai#20708 as
`9b384f63a`, PR objectstack-ai#20717 as `cbaf04c1f`). That is **76 sites on 76 lines
in 22 files, covering 14 numbers**:

- 42 census sites (every census site this package has);
- 34 sites in test comments, which the census defers.

The raw scan found no dead site the gate's grammar cannot see (see
Acceptance notes), so there is no third class this time.

Each rewritten line now cites the commit in `origin/main` history that
decided what the line describes, and says in its own words what was
decided: **13 distinct shas**. No number in this package has an ADR or
ruling record of its own in the repository (a grep of `docs/adr/` for
all 14 finds none, and a grep of the rest of `docs/` finds none either),
so every anchor is a commit, per ruling C's order. No number was
dropped.

Only comments changed. Every touched source file keeps its line count
(78 lines out, 78 in, over 22 files), so no line citation into these
files moves. 2 of those 78 lines hold no dead citation: they are reflow
lines, listed under Wordings below. No code token moves (see the guard
below).

**No citation number is added.** Every tracker number on an added line
was already on the line it replaces: `objectstack-ai#10861` (5 lines), `objectstack-ai#12776` (3),
`objectstack-ai#10413` (2), `objectstack-ai#16750` (2), and `objectstack-ai#10759`, `objectstack-ai#11152`, `objectstack-ai#5716` and the
decision-batch ordinal `objectstack-ai#59` once each. Each tracker number among them
resolves. Over the whole diff, added minus removed is 0 or negative for
every number, and no number is new to the diff. No PR number is the
citation on an added line: the two `PR #N` spellings in scope became
their pull request's squash commit, and `objectstack-ai#16750` stays only as the
convenience link beside `ed7243d52`, on the line it already stood on.

Eight dead sites are left on purpose, all of them test strings (see the
list below).

One more file: a `patch` changeset for `@objectstack/service-analytics`,
because the rewritten docblocks and inline comments ship (see Changeset
below).

The `AnalyticsResultWithDrill` type and its four sidecar members are not
touched: its docblocks carry no dead number (`objectstack-ai#20644`, `objectstack-ai#3214` and
`objectstack-ai#1752` all resolve).

## Census: `service-analytics`, before and after

**Instrument (A1).** The gate's own `node
scripts/check-issue-citations.mjs --census --json`, read-only and
unchanged. The count below is its `allocated-but-absent` findings under
`packages/services/service-analytics/`. Each run counts as a reading
only because its board frontier equals the newest issue number, read by
a separate request just before and just after the run.

| reading | tree | board | whole-repo `allocated-but-absent` |
service-analytics sites | lines | files | numbers |
|---|---|---|---|---|---|---|---|
| before | base `cbaf04c1f`, run 2026-09-29T21:41:53Z to 21:45:05Z |
enumerated, 186 pages, frontier objectstack-ai#20721 (newest objectstack-ai#20721 before and after),
18,548 numbers | 1,161 | **42** | 42 | 10 | 10 |
| after | head `967d73531`, run 21:55:23Z to 21:58:36Z | enumerated, 186
pages, frontier objectstack-ai#20723 (newest objectstack-ai#20723 before and after), 18,550 numbers
| 1,119 | **0** | 0 | 0 | 0 |

The before count matches the seat's census at the claim and A1 (42
sites): the two comments PR objectstack-ai#20712 rewrote in `analytics-service.ts` did
not move it. The whole-repo drop is 42, exactly this diff's census
sites. The `resolves` tally is 32,991 in both runs, and
`resolves-as-pull-request` (1,984) and `cross-repo-unjudged` (995) did
not move either. The after run was taken on `967d73531`; the head
`82d2b40b2` adds only the changeset. No run was truncated or discarded:
both enumerations read 186 pages at the newest frontier.

**Supplementary instrument, the whole scope.** The census does not read
test files or strings, and this stage's scope includes test comments. So
a second reading runs the gate's own exported `extractCitations`
(whole-file and comment-prose projections) and `namesThisRepository`
over every `.ts` file under `service-analytics/src` (162 files). It
takes its verdicts from the before census's own board reading rather
than from a second enumeration: a number is dead when that census
reported it `allocated-but-absent`, and alive when that census judged it
on this board anywhere (its `--list` extraction) and did not report it.
The 21 numbers the census never saw, because they stand only in test
files or strings here, were read one by one on the issues endpoint: 17
answer 200, and `objectstack-ai#16778`, `objectstack-ai#16860`, `objectstack-ai#16918` and `objectstack-ai#17125` answer 404.

| reading | citations | dead | src comment | test comment | src string |
test string |
|---|---|---|---|---|---|---|
| before, `cbaf04c1f` | 3,514 | **84** | 42 | 34 | 0 | 8 |
| after, `967d73531` | 3,438 | **8** | 0 | 0 | 0 | 8 |

Its src-comment column equals the census's 42, which is the control on
the second instrument. The 3,410 live citations and the 20 cross-repo
citations are the same in both readings, and the drop of 76 citations is
exactly the rewritten sites. A third, raw reading (every `#` followed by
2 to 6 digits, whatever surrounds it) finds 3,598 occurrences and 84
dead before, 3,522 and 8 after; its residue equals the gate's residue
site for site, and it sees no dead site beyond the gate.

## Per-number table

Sites and files count every dead occurrence in scope at the base
(comments and strings, tests included). `rewritten / left` counts the
sites rewritten and the sites left. Each anchor was read in its message
and diff, not only its subject, and `git blame` at the base puts every
rewritten line in its anchor commit or in a later commit that descends
from it (`merge-base --is-ancestor` exit 0 for each pair).

| number | sites / files | rewritten / left | anchor: what it decided |
|---|---|---|---|
| `objectstack-ai#11461` | 20/2 | 19/1 | `399ecad58`: a cross-object leaf in one
measure's own `filter` (the third producer, lowered onto
`aggregations[].filter`) is refused on both ObjectQL doors with
`INVALID_FIELD` / 400 naming the measure, folded into the one member
view, with insertion order keeping every earlier refusal's message. The
last line of its message names `objectstack-ai#11461` as the card it settles. New to
the sweep |
| `objectstack-ai#17130` | 17/5 | 13/4 | `54b3d1d4a` (PR objectstack-ai#17336): the row-scope
resolution refusals carry `READ_SCOPE_COMPILE_FAILED` / 500 through one
constructor, so `queryDataset`'s catch re-throws them instead of reading
their words, every message byte-unchanged; plus the source-derived
wording-collision guard. Named in its diff only (18 added lines carry
the tag). New to the sweep |
| `objectstack-ai#17124` | 12/8 | 10/2 | `86c505286` (PR objectstack-ai#17593):
`explicitDateRangeWindow` is the one reading of `dateRange`'s array arm
on all four faces, and an array that is not two string bounds is refused
with `ANALYTICS_DATE_RANGE_UNRECOGNIZED` / 400. Named in its diff only
(its changeset file is `17124-daterange-array-arm-arity.md`). New to the
sweep |
| `objectstack-ai#12209` | 10/5 | 10/0 | `017130a09` (PR objectstack-ai#12318): a custom-SQL measure
is refused on the ObjectQL aggregate path with `INVALID_FIELD` / 400,
keyed on the `EXPRESSION_METRIC_TYPES` partition shared with
`NativeSQLStrategy`. Its message records the two failure modes the lines
describe (`driver-sql` blaming a `function` key, the in-memory evaluator
answering `null` per bucket). Named in its diff only. New to the sweep |
| `objectstack-ai#16778` | 5/1 | 4/1 | `357f4992b`: the compile-leg refusal of an
aggregate a datetime measure's field type cannot carry, scoped to
temporal source fields. The squash commit of the pull request that was
`objectstack-ai#16778`; its subject carries the number. New to the sweep |
| `objectstack-ai#12940` | 4/2 | 4/0 | `aa16721b6` (PR objectstack-ai#13361): this package's
consumer-local `executeAggregate` config mirrors (the plugin options and
`AnalyticsServiceConfig`) narrow `aggregations[].method` to
`AggregationFunction`, after `objectstack-ai#12776` narrowed the contract. Named in
its diff only. New to the sweep |
| `objectstack-ai#17015` | 4/2 | 4/0 | `0da638cd9`: the closed `dateRange` preset
vocabulary is lowered once and the rest refused, the `[range, range]`
fallback is removed from the faces it reached, and the shared
conformance kit holds them. The squash commit of the pull request that
was `objectstack-ai#17015`. New to the sweep |
| `objectstack-ai#16860` | 3/1 | 3/0 | `041d9fdc6`: the object-level read grant is
asked at the analytics door, and its bridge to the `security` service
resolves an explicit three-way (absent admits; throwing or method-less
denies at `error`, finding F3 in its message). The squash commit of the
pull request that was `objectstack-ai#16860`. New to the sweep |
| `objectstack-ai#12248` | 2/1 | 2/0 | `8425c17cc`: the five ruled engine members,
`getDriverForObject?` and `resolveEffectiveDatasource` among them,
adopted onto `IDataEngine`, and `getObject` typed. Its subject names it.
Stage 5's and the spec stage's anchor |
| `objectstack-ai#16685` | 2/2 | 2/0 | `ed7243d52` (PR objectstack-ai#16750): `boolean` / `toggle`
accepted for `sum` / `avg` / `min` / `max` in the aggregate × field-type
table, holding maintainer ruling `objectstack-ai#11152`. Its subject names it. The
spec stage's anchor |
| `objectstack-ai#17125` | 2/2 | 2/0 | `5d12b16e7`: the row-scope bridge tells an
absent security service from a broken one, so a broken one refuses the
query. The squash commit of the pull request that was `objectstack-ai#17125` (404 on
the pulls endpoint too). New to the sweep |
| `objectstack-ai#16918` | 1/1 | 1/0 | `5d12b16e7`: the same commit. Its changeset's
headline names `objectstack-ai#16918` as the card it answers, and its diff writes the
line (`admission-bridge-resolution.test.ts:120`) |
| `objectstack-ai#6123` | 1/1 | 1/0 | `59d1933f9`: `err.code` lands at `error.code`,
not `error.details.code`; the commit that wrote this very line. The
`runtime` stage's anchor |
| `objectstack-ai#13279` | 1/1 | 1/0 | `6a180e42d`: permission-store read failures
fail loud, and the same commit renames
`metadata/src/utils/schema-sync-errors.ts` to
`packages/types/src/driver-error-classification.ts`, the move the line
describes. The anchor of stages 2, 5 and 6, and of the `types`, `rest`
and `runtime` stages |

Every cited sha matches exactly one commit (`git rev-parse
--disambiguate`, count 1 for each of the 13), and every one is an
ancestor of the base (`merge-base --is-ancestor`, exit 0 for all 13;
control leg: stage 1's landing `422db788a` exit 0; the history is
complete, `--is-shallow-repository` false, 15,135 commits). Each of the
14 numbers answers 404 on the issues endpoint, read one by one;
`objectstack-ai#16778`, `objectstack-ai#16860`, `objectstack-ai#17015` and `objectstack-ai#17125` answer 404 on the pulls
endpoint too.

## Wordings to check

- **Bracket tags.** `[#N]` became `[commit SHA]`, as in stage 7;
`[objectstack-ai#10861 / objectstack-ai#11461]` and `[objectstack-ai#10861, objectstack-ai#11461]` keep the live `objectstack-ai#10861` beside
the new sha.
- **The boolean rows, `measure-result-type.ts:115-116` and
`aggregate-datetime-measure-refusal.test.ts:65-66`.** 「objectstack-ai#16685 ruled A,
landed as objectstack-ai#16750」 and 「objectstack-ai#16685 was ruled A and objectstack-ai#16750 added」 became
「commit ed7243d (objectstack-ai#16750) added those rows」 and 「commit ed7243d
(objectstack-ai#16750) added」. 「ruled A」 named an option on the dead card;
`ed7243d52`'s message records the decision itself. Line 116 of the first
file and line 66 of the second are the 2 reflow lines: each keeps the
`objectstack-ai#16750` it already carried.
- **PR numbers, `read-scope-resolution-envelope.test.ts:25` and
`refusal-wording-collision.test.ts:21`.** 「PR objectstack-ai#17125's refusal」 became
「Commit 5d12b16's refusal」, the pull request's squash commit.
- **`read-scope-refusal.ts:29`.** 「objectstack-ai#17130 exists to remove it」 became
「commit 54b3d1d was made to remove it」, the form stage 6 used.
- **`refusal-wording-collision.test.ts:49`.** 「the exact move objectstack-ai#17130
forbids」 became 「the exact move commit 54b3d1d ruled out」; its message
says the fix is the declaration, not a luckier string.
- **`read-scope-resolution-envelope.test.ts:161`.** The verb after the
number moved from present to past tense with the sha.
- **`measure-expression-both-strategies.test.ts:45` and `:166`.**
「deleting the objectstack-ai#12209 arm in」 became 「deleting the arm commit 017130a
added in」, and 「every objectstack-ai#12209 refusal」 became 「every custom-SQL refusal
(commit 017130a)」.
- **`dataset-executor.ts:609`.** 「objectstack-ai#17015's kit」 became 「commit
0da638c's kit」, the conformance kit that commit built.
- **`plugin.ts:116`.** 「and in objectstack-ai#12209:」 became 「and in commit
017130a:」, whose message records the two ways the engine failed.
- **`analytics-service.ts:238`.** 「objectstack-ai#13279 moved it there」 became 「commit
6a180e4 moved it there」; that commit's diff is the rename.

## The 8 sites left

- **Test strings, 8 sites**, left as stages 1 to 7 left theirs, all
`describe` / `it` titles:
  - `crossobject-conjunct-refusal.test.ts:589` (`objectstack-ai#11461`);
  - `aggregate-nontemporal-measure-refusal.test.ts:243` (`objectstack-ai#16778`);
  - `date-range-array-arm-arity.test.ts:213` and `:294` (`objectstack-ai#17124`);
- `read-scope-resolution-envelope.test.ts:155`, `:199` and `:226`, and
`refusal-wording-collision.test.ts:336` (`objectstack-ai#17130`).
- There is no operator string, generated file or quoted ruling carrying
a dead number in this package. It has no generated file at all.

## Mechanical guard: no code token moves

The guard compares the TypeScript parser's leaf nodes, with comments as
trivia and JSDoc nodes never visited, base `cbaf04c1f` against head.
Template literals are therefore read in context. It ran over all 22
touched `.ts` files.

- Real run: 26,705 base leaf tokens, **0 files with a token change**
(exit 0).
- Comment control in `plugin.ts` (「refusal buys is in」 to 「refusal earns
is in」): 0 files changed, as expected (exit 0).
- Positive control, a code token added in `plugin.ts` (`field: a.field,`
given `as string`): DIFFER (exit 1).
- Positive control, one digit changed inside a kept test title
(`date-range-array-arm-arity.test.ts:213`): DIFFER (exit 1).

Every mutation went through `scripts/ablation-replace.mjs`, and each
landed (anchor 1 to 0, blob changed). Each restore was proven
byte-identical to the HEAD blob (`ad3dc9fff4d3`, `a606ffbb6ead`), with
`git diff HEAD` empty and a clean tree afterwards.

## Changeset

This change ships bytes, so a `patch` changeset for
`@objectstack/service-analytics`
(`.changeset/20596-service-analytics-provenance-anchors.md`) is
included. Its body is stage 7's, word for word, with the package name
changed.

Measured on the built package (A3): `files[]` is `dist`, `README.md` and
`CHANGELOG.md`. After the build (a cache miss for this package, so
`dist` is this head's source), the rewritten comments reach `dist`:
`399ecad58` 6 times in each of `dist/index.js`, `index.cjs`,
`index.d.ts` and `index.d.cts`; `86c505286` twice in each JS file and
once in each declaration file; `54b3d1d4a` once in all four; `aa16721b6`
once in each JS file and twice in each declaration file; `017130a09`
once in each JS file. Positive controls: the unchanged line 「none of the
coverage: a compiled measure's own」, in the same docblock as the shipped
rewrite at `objectql-strategy.ts:744`, is found once in each of the four
files, and the unchanged line 「back into line. Widening it here again
would not be a local matter」 beside the shipped rewrite at
`analytics-service.ts:559` once in each declaration file. A
never-written negative phrase appears nowhere in `dist`. None of the 14
dead numbers is left anywhere in `dist`.

## Gates (head `82d2b40b2`)

- **Citation judging, as CI runs it:** `pnpm check:issue-citations`
(self-test) exits 0. `node scripts/check-issue-citations.mjs` exits 0:
the diff-scoped run judged 11 citations across 10 files; 10 resolve and
1 resolves as a pull request (`objectstack-ai#16750`, the convenience link that
already stood on its line).
- **Doc authoring:** `pnpm check:doc-authoring` exits 0.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` at `82d2b40b2` derived 62 commands:
all 56 derived at dispatch, plus `check:engine-double-contract`,
`check:objectql-double-limit`, `check:query-options-erasure`,
`check:type-check-coverage`, `check:type-check-debt` and
`check:where-matcher`. Each ran with its exit code captured before any
pipe, and all 62 exit 0. `--ran`, fed each command with its exit code,
reports 62 run, 0 NOT MEASURED (a derived zero), 0 unrun, and exits 0. A
full `turbo run build` of `./packages/*` and `./packages/*/*` ran first
under the shared verify lock (71 of 71 tasks, exit 0), so no gate hit an
unbuilt workspace.
- **Roster families the derivation lists outside its commands** (their
rosters sit in directories this diff touches): `node
scripts/check-changeset-fixed.mjs`, `pnpm check:authz-resolver`, `pnpm
check:error-code-casing` and `pnpm check:filter-alias-parity`, each exit
0.
- **Tests and typecheck, under the verify lock:**
- `pnpm --filter @objectstack/service-analytics test`: 137 files pass
and 3,216 tests pass. That is every test file in the package, the 12
touched ones included.
- `pnpm --filter @objectstack/service-analytics typecheck` exits 0 (`tsc
--noEmit` on `tsconfig.json`). `--listFiles`: the program holds all 162
files under `src/`, the 137 test files and all 22 touched files
included.
- **Lint, as a proven narrowing:** `eslint --no-inline-config --format
json` over the 22 touched `.ts` files gives 22 files, 0 errors and 0
warnings. All 22 are in eslint's own population (`isPathIgnored` is
false for each; a `dist` file, as the control, is ignored).
`eslint.config.mjs` never enables type-aware linting (no
`parserOptions.project`, as its own lines 327-328 state), so a comment
edit here cannot move the verdict on any untouched file. The repo-wide
`pnpm lint` is CI's run.
- **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of
the 23 changed files for control bytes finds none.

## Acceptance notes

- **The gate-invisible spellings, grepped as the claim asked.**
`CITATION_RE` refuses a hyphen after the digits and a `/` before the `#`
(objectstack-ai#20636), and `NON_CITATION_HEADS` excuses a number after the word
「option」. In this package:
- `#N-word`: 8 lines by a plain grep, and 7 once a hyphen before the `#`
is excluded too, which is the claim's 7. The eighth is
「pre-objectstack-ai#10413-phase-2」 (`execution-context-bridge.test.ts:223`). The
numbers, `objectstack-ai#10413`, `objectstack-ai#5298`, `objectstack-ai#13570` and `objectstack-ai#13640`, all resolve.
- `#A/#B`: 29 lines, the claim's 29, over 28 distinct numbers. All
resolve; `objectstack-ai#2149`, which the census never judged, was read on its own.
  - `option #N`: none.
So nothing here needed a rewrite beyond the gate, and the raw scan
agrees.
- **「This card」 phrases are left.** 113 lines in 39 files of this
package speak of 「this card」, 「that card」 or 「the card」. They carry no
number, neither instrument sees them, and most sit in blocks whose
numbers still resolve. Stage 7 rewrote two such lines as lost referents;
here none is changed, because the phrase runs through the whole package
and rewriting a subset would be arbitrary.
- **Prose that names `queryDataset`'s catch, not changed.** Nine comment
lines say `queryDataset`'s catch. Since `10c36cc43` that catch sits in
the private `answerDataset`, whose docblock calls it the body of
`queryDataset`, so the lines still hold at the level of the public
method. This is not a dead citation, so it is outside this stage.
- **The census instrument did not truncate in this stage.** Both
enumerations read 186 pages at the newest frontier.
- **Anchors the next stages can reuse**, each checked here: `objectstack-ai#11461` →
`399ecad58`; `objectstack-ai#17130` → `54b3d1d4a`; `objectstack-ai#17124` → `86c505286`; `objectstack-ai#12209` →
`017130a09`; `objectstack-ai#16778` → `357f4992b`; `objectstack-ai#12940` → `aa16721b6`; `objectstack-ai#17015` →
`0da638cd9`; `objectstack-ai#16860` → `041d9fdc6`; `objectstack-ai#17125` and `objectstack-ai#16918` →
`5d12b16e7`.
- **Base.** The branch is on `main` at `cbaf04c1f`. `main` has since
moved four commits (`3711e0b76`, `61455de27`, `6afccda5a`, `671d4c164`).
They touch `packages/spec`, `packages/metadata/package.json`,
`pnpm-lock.yaml`, docs and changesets, and no file under
`service-analytics` or in this diff, so no merge was taken; the merge
queue rebuilds on the merged generation. One of them, `671d4c164`,
declares the four drill-through sidecars on `AnalyticsResult` in the
spec. This diff leaves the local `AnalyticsResultWithDrill` untouched,
as the claim requires.

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

---------

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

This branch had an error being deployed

1 failed deployment
Preview — 50f4e726 Deployed Jan 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants