Skip to content

Add Changesets and GitHub Actions automation - #4

Merged
huangyiirene merged 7 commits into
mainfrom
copilot/add-necessary-automation-work
Jan 18, 2026
Merged

huangyiirene merged 7 commits into
mainfrom
copilot/add-necessary-automation-work

Conversation

Copilot AI commented Jan 18, 2026 •

Copy link
Copy Markdown
Contributor

Implements version management and CI/CD automation infrastructure.

Changes

Changesets Integration

  • Install @changesets/cli (v2.29.8) for semantic versioning and changelog automation
  • Configure for public npm publishing to @objectstack/spec
  • Add npm scripts: changeset, version, release

GitHub Actions Workflows

  • .github/workflows/ci.yml: Build validation on push/PR (Node 20, TypeScript compilation)
  • .github/workflows/release.yml: Automated npm publishing via Changesets action
  • Minimal GITHUB_TOKEN permissions for security (contents: read for CI, contents: write + pull-requests: write for releases)

Package Metadata

  • Add repository field pointing to objectstack-ai/spec

Usage

Contributors create changesets when making changes:

npm run changeset  # Describe changes interactively

Maintainers merge the auto-generated "Version Packages" PR to trigger npm publish. Requires NPM_TOKEN secret configured in repository settings.

Original prompt

添加必要的自动化工作包括changeset


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

Copilot AI and others added 3 commits January 18, 2026 09:12
- Install @changesets/cli for version management
- Configure Changesets for public npm publishing
- Add GitHub Actions CI workflow for build and type checking
- Add GitHub Actions Release workflow for automated publishing
- Add changeset npm scripts to package.json
- Add repository field to package.json

Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
The build step already runs tsc which will fail on errors

Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
Set minimal permissions (contents: read) to limit GITHUB_TOKEN scope

Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
Copilot AI changed the title [WIP] Add necessary automation work including changeset Add Changesets and GitHub Actions automation Jan 18, 2026
Copilot AI requested a review from huangyiirene January 18, 2026 09:17
Copilot AI and others added 3 commits January 18, 2026 09:21
Regenerate package-lock.json to fix npm ci failure in CI workflow.
Updates @types/node from 12.20.55 to 25.0.9 and adds missing undici-types dependency.

Co-authored-by: huangyiirene <7665279+huangyiirene@users.noreply.github.com>
@huangyiirene
huangyiirene marked this pull request as ready for review January 18, 2026 09:25
@huangyiirene
huangyiirene merged commit f046f0c into main Jan 18, 2026
1 check passed
os-zhuang pushed a commit that referenced this pull request May 21, 2026
The real moat of metadata-driven development is not 'low-code UI', it
is that the entire business system is small enough to fit in an AI
agent's context window. Make this an explicit, top-level value across
README and the concept docs.

- README.md
  - Add a 'Key Features' bullet on ~100x less code -> AI maintainability
  - Add 'Code footprint' and 'AI maintainability' rows to the
    Retool/Appsmith comparison table
  - Rewrite the 'Why AI-native?' intro to anchor the value on
    'fit in an agent's context window'

- content/docs/index.mdx
  - Add a second callout under the 'not a low-code UI builder' line
    explaining the ~100x code reduction and AI-co-maintenance angle
  - Fix stale 'npx @objectstack/cli init' -> 'npx create-objectstack'
    quick-start command (matches updated README)

- content/docs/concepts/metadata-driven.mdx
  - Rename benefit #4 'Reduced Boilerplate' -> '~100x Less Code -
    Sized for AI Agents' and reframe around context-window fit
  - Clarify that what gets generated is full CRUD + REST + typed SDK
    + MCP tools + validation + permission scaffolding, not just CRUD

- content/docs/concepts/north-star.mdx
  - Add a sixth non-negotiable tenet: 'Compact by Construction'
    -> a typical enterprise app fits in ~1% of a hand-written
    equivalent, small enough for an AI agent to load and refactor
    end-to-end. Explicitly call this out as the real moat.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
xuyushun441-sys pushed a commit that referenced this pull request May 22, 2026
Introduces an opt-in path in ObjectStackProtocolImplementation.saveMetaItem
that writes overlay metadata through SysMetadataRepository.put instead of
the raw engine, so writes append to the change-log and emit HMR seq events.

Behavioural changes (all behind options.useRepositoryWritePath /
OBJECTSTACK_USE_REPOSITORY_WRITE_PATH=1):
- saveMetaItem request gained optional parentVersion (If-Match) and
  actor fields. ConflictError -> 409 metadata_conflict.
- Plural type aliases (views, dashboards, ...) normalized to singular
  before the repo's overlay-allowlist gate (rubber-duck #5).
- Object-registry mutation moved AFTER successful put() so a conflict
  does not leave the in-memory registry stale (rubber-duck #3 invariant
  test added).

Repo/test-fake fixes uncovered by rubber-duck review:
- SysMetadataRepository.put/delete now update/delete by row id because
  the engine's strict .update requires id or multi:true (rubber-duck #1).
- sys_metadata.checksum column widened from 64 -> 71 chars to hold the
  sha256: prefix produced by hashSpec() (rubber-duck #2).
- Three test fake engines extended to support both overlay-tuple and
  id-based where lookups.

333/333 objectql tests pass.

Deferred to PR-10d.4: REST plumbing for parentVersion/actor
(rubber-duck #6), race-window retry for omitted parentVersion
(rubber-duck #4), default flag flip + legacy path removal.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
xuyushun441-sys pushed a commit that referenced this pull request May 24, 2026
Proposes that every Action opts in to AI exposure via a single `ai:` block
on ActionSchema, and the runtime auto-derives AIToolDefinitions from the
existing ActionRegistry. Eliminates the need to maintain parallel skill /
tool code for every business operation an admin can already perform.

- Adds opt-in `ai: { exposed, description, paramHints, outputSchema, ... }`
  block to @objectstack/spec ui/action.zod.ts
- Adds ActionRegistry.toolsForAi(opts) in @objectstack/runtime
- Wires service-ai/agent-runtime to merge action-tools into availableTools
- Routes LLM tool_calls with meta.kind='action' through ActionRegistry so
  permissions, validation, hooks, audit, and transactions all apply uniformly
- Confirmation defaults derived from existing confirmText / type='delete'

Authored from HotCRM v1.1 planning. HotCRM will be the first consumer:
delete src/skills/, convert each business skill to defineAction with ai
exposed, ship the 'Operational Parity' story as Wow #4.

Refs ADR-0003, ADR-0008, ADR-0009, ADR-0010.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
xuyushun441-sys added a commit that referenced this pull request Jun 13, 2026
#1821)

Small models (e.g. claude-haiku) sometimes answered a "draw a bar chart"
request with a markdown TABLE — running query_data/aggregate_data and
formatting the numbers — instead of calling visualize_data. This was a
tool-selection problem, not a capability gap: the chart preference was buried
as guideline #7 and competed with guideline #4 ("format with markdown
tables").

- data-explorer-skill.ts: add a prominent "Choosing the right tool" section
  ABOVE the guidelines — chart intent (incl. CN terms 图表/柱状图/折线图/饼图/画图)
  → MUST call visualize_data; never substitute a table; reconcile the
  table-formatting guideline; fix duplicate guideline numbering.
- visualize-data.tool.ts: strengthen the tool description to be imperative
  ("the ONLY tool that draws a chart… you MUST call this, not a table; if you
  already fetched the numbers, still call visualize_data to render them").

Prompt-only tuning — no behavior/contract change. Raises the likelihood the
model reaches for visualize_data on a plain chart request without an explicit
"use visualize_data" nudge.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…tack-ai#18681)

Fixes objectstack-ai#18456

Clause-②: no

`scripts/pm/` sits outside every workspace package, and the root package
is private, so no
`files[]` can ship this diff — `skip-changeset`.

## The defect

`check-clause2-carriers --pair` is the landing pre-check every seat
runs, and on one pair
(PR objectstack-ai#17917 / card objectstack-ai#17425) it answered **0 at 02:57Z, 4 at 03:04:09Z and
0 at 03:58:33Z on
2026-09-13 with an identical script blob**. Two explanations were ruled
out with controls
(no comment on that thread was ever edited; the board is resolved from
the environment, never
from the working directory), so the cause is still UNKNOWN — and the
three runs could not be
compared, because not one of them had SAID what it read. The judging
half is already
deterministic given a fixed document (`--pair-json` proves that); what
was unpinned is **what
document the live path builds**. This states it: every `--pair` run now
closes with a fenced
`clause2 input record` block on stderr, with the same field roster on
every exit, so two runs
that disagree are settled by **diffing their two blocks** — never by
re-running until one side
wins. ⛔ No guess at the cause is dressed as a fix here: no predicate, no
state, no row, no
count and no exit code reads one character of the record, and the
judging half is untouched.

## The record's field roster

Rendered from `INPUT_RECORD_RUN_FIELDS` and `INPUT_RECORD_PAIR_FIELDS`
and from nowhere else,
so a field cannot silently disappear: a declared field this run could
not fill renders with an
explicit token rather than vanishing, and a field the builder fills that
the roster does not
declare is NAMED in the block (`record.undeclared`). Values too long for
one line continue on
indented lines under their key.

| half | fields |
|:--|:--|
| run | `record.version` · `run.utc` · `run.mode` · `run.script.path` ·
`run.script.blob` · `run.script.bytes` · `run.node` · `board.repo` ·
`board.source` · `read.plan` · `read.api` · `read.token` · `read.served`
· `read.pair-json` · `run.requests` · `pairs.derived` |
| per pair (`pair.N.`) | `pr` · `card` · `derivation` · `head-sha` ·
`card-comments` · `card-comment-ids` · `card-comment-newest` ·
`pr-comments` · `pr-comment-ids` · `pr-comment-newest` · `claim.rule` ·
`claim.selected` · `claim.rejected` · `claim.clause2-line` ·
`pr-body.clause2-line` |

Four of them are worth naming for WHY they are there:

- **`run.requests`** — every read the run issued, in order, with its
channel, its exact path
and its **row count**. A page asked for with `per_page=100` that answers
with exactly 100
rows is the one shape a truncated read and a complete one share, and
nothing printed it.
- **`claim.rule` + `claim.selected` + `claim.rejected`** — the carrier,
the rule that picked it
and every candidate it did not pick, each with its reason. That
separates "the two runs
selected different comments" from "the two runs applied different
rules".
- **`claim.selected`'s body fingerprint** (bytes + `sha256:`) — the
field the measured 0/4/0
actually needs. A `misplaced` verdict on that thread requires the
governing claim to have
carried no readable declaration while a superseded one did; same ids
with a different verdict
is only possible if the BYTES differed, and the ids were all anybody
could see.
- **`run.script.blob`** — git's blob hash of this file, beside the path
it ran from. "The blob
was identical on both sides" was a claim in the incident; it is now a
printed fact any seat
checks with `git hash-object`. On this PR's head it reads
`25d204236aa8296644813109fa77541d6efe1644`,
which is exactly `git rev-parse
HEAD:scripts/pm/check-clause2-carriers.mjs`.

The `--json` sweep carries the same record under `inputs` — the same
record, ⛔ never a second
format.

## The two live blocks the card names

`--pair 17917` — the pair from the card. Both it and objectstack-ai#18654 have since
merged, so `--pair`
answers **exit 2** on each today (the pair cannot be formed from a
closed PR). ⭐ That is
precisely the class of exit the old code said the least about, and the
block is now complete
on it:

```text
----- clause2 input record v1 -----
record.version: 1
run.utc: 2026-09-17T14:10:26.210Z
run.mode: --pair 17917
run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs
run.script.blob: 25d2042 (git blob sha1 — check it with `git hash-object` on the path above)
run.script.bytes: 513169
run.node: v22.22.2
board.repo: objectstack-ai/objectstack
board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered
read.plan: (i) token then (ii) token-less public read
read.api: https://api.github.com (REST, accept application/vnd.github+json)
read.token: present
read.served: token=1, public=0, pair-json=0
read.pair-json: (not named — this run read the network)
run.requests: 1 read(s), in the order they were issued
  objectstack-ai#1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s))
pairs.derived: 0 pair(s)
record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument.
----- end clause2 input record -----
```

`--pair 18654` — the pair this seat landed today, which answered 0 at
12:32Z and is likewise
merged now (**exit 2**):

```text
----- clause2 input record v1 -----
record.version: 1
run.utc: 2026-09-17T14:10:27.163Z
run.mode: --pair 18654
run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs
run.script.blob: 25d2042 (git blob sha1 — check it with `git hash-object` on the path above)
run.script.bytes: 513169
run.node: v22.22.2
board.repo: objectstack-ai/objectstack
board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered
read.plan: (i) token then (ii) token-less public read
read.api: https://api.github.com (REST, accept application/vnd.github+json)
read.token: present
read.served: token=1, public=0, pair-json=0
read.pair-json: (not named — this run read the network)
run.requests: 1 read(s), in the order they were issued
  objectstack-ai#1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s))
pairs.derived: 0 pair(s)
record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument.
----- end clause2 input record -----
```

⭐ `diff` of those two blocks is **four lines**: `run.utc` and
`run.mode`, twice. Same roster,
same order, same shape — which is the property the card asked for.

## A live block on exit 0

`--pair 18659` (open at the time of writing) — **exit 0**, the full pair
half:

```text
----- clause2 input record v1 -----
record.version: 1
run.utc: 2026-09-17T14:10:36.744Z
run.mode: --pair 18659
run.script.path: /home/user/objectstack-issue-18456/scripts/pm/check-clause2-carriers.mjs
run.script.blob: 25d2042 (git blob sha1 — check it with `git hash-object` on the path above)
run.script.bytes: 513169
run.node: v22.22.2
board.repo: objectstack-ai/objectstack
board.source: default — NEITHER PM_SWEEP_REPO NOR GITHUB_REPOSITORY answered
read.plan: (i) token then (ii) token-less public read
read.api: https://api.github.com (REST, accept application/vnd.github+json)
read.token: present
read.served: token=5, public=0, pair-json=0
read.pair-json: (not named — this run read the network)
run.requests: 5 read(s), in the order they were issued
  objectstack-ai#1 (i) token /repos/objectstack-ai/objectstack/pulls?state=open&per_page=100&page=1 -> HTTP 200 (21 row(s))
  objectstack-ai#2 (i) token /repos/objectstack-ai/issues/18443 -> HTTP 200
  objectstack-ai#3 (i) token /repos/objectstack-ai/issues/18443/comments?per_page=100 -> HTTP 200 (4 row(s))
  objectstack-ai#4 (i) token /repos/objectstack-ai/objectstack/pulls/18659/files?per_page=100&page=1 -> HTTP 200 (1 row(s))
  objectstack-ai#5 (i) token /repos/objectstack-ai/issues/18659/comments?per_page=100 -> HTTP 200 (1 row(s))
pairs.derived: 1 pair(s)
pair.1.pr: 18659
pair.1.card: 18443
pair.1.derivation: `closing-keyword` (via a closing keyword) — body line: Fixes objectstack-ai#18443
pair.1.head-sha: 1344eb5
pair.1.card-comments: 4 row(s)
pair.1.card-comment-ids: 5713976124,5714587497,5714873191,5715029659
pair.1.card-comment-newest: 5715029659 at 2026-09-17T13:19:56Z
pair.1.pr-comments: 1 row(s)
pair.1.pr-comment-ids: 5715030051
pair.1.pr-comment-newest: 5715030051 at 2026-09-17T13:19:57Z
pair.1.claim.rule: the GOVERNING claim — the NEWEST comment whose body carries a line beginning `Claim:`/`Claimed:` AND whose `Branch:` line parses at least one protocol-shaped branch (newest by `created_at`; an unreadable stamp or a tie falls back to thread order, later row wins). The pool is every claim comment sharing that `created_at`; when NO claim names a branch at all, every claim comment is the pool. ⛔ Not earliest, ⛔ not a session match, ⛔ not the one whose body mentions the key.
pair.1.claim.selected: 1 comment(s) in the pool
  5714587497 at 2026-09-17T12:46:45Z — 2159 bytes, sha256:795e1df6c9fd
pair.1.claim.rejected: none — every claim comment on this thread is in the pool
pair.1.claim.clause2-line: DECLARED `no` — Clause-②: no
pair.1.pr-body.clause2-line: DECLARED `no` — Clause-②: no ⚠️ stated as an INPUT only — ⛔ no row here judges the PR body; the declaration limb is judged from the card, and `check-changeset-no-major.mjs` is what reads this line.
record.how-to-read: two runs that DISAGREE about one pair are settled by diffing their two blocks — ⛔ never by re-running until one side wins. The blob line says whether the two runs were even the same instrument.
----- end clause2 input record -----
```

## Pins

Battery **objectstack-ai#18456: the `--pair` input record — the same block on every
exit, so two runs that
disagree can be diffed**, registered in `SELF_TEST_BATTERIES` with a
floor of **38**; **41**
cases register. `SELF_TEST_BATTERY_FLOOR` raised 26 → 27 by exactly the
one battery this adds.

What is pinned, in the card's own terms:

- the record is **present and complete on exit 0**, on the **exit-4
(MISPLACED)** shape and on
a **refusal that formed no pair** — all three key lists asserted equal;
- the **field roster** cannot lose a field: a declared field that was
never filled still renders
(with `INPUT_RECORD_UNSET`), an empty record still carries every
declared key, and a key
  outside the roster is named rather than printed in silence;
- the **selected-claim rule is stated**, and it is the one constant
`claimCarrierSelection`
  applies — so the printed rule cannot drift from the applied one;
- a **rejected candidate is named with its reason**, and a thread with
nothing rejected says so;
- a **`--pair-json` run names that read path as such** and names the
document;
- the body fingerprint **moves when only the bytes move** while every id
field stays identical —
  the measured shape, asserted directly;
- `gitBlobSha1` is pinned against two values `git hash-object` prints.

⛔ CONTROLS in the same battery: the block carries no verdict, no exit
code and no finding row;
building it changes no reading; and the selection the block prints IS
the pool `cardDeclaration`
judged (ONE derivation — `cardDeclaration` now calls
`claimCarrierSelection` instead of deriving
the pool inline, so the record and the verdict cannot describe two
different comments).

`--self-test` on this head: **786 cases pass, exit 0** (745 before;
+41).

## Ablation

From the committed tree, blob `25d204236aa8296644813109fa77541d6efe1644`
(= this PR's head
blob), the pair half of the record removed on disk, mutation proved
before the run, restore by
blob hash under a `trap`:

```text
HEAD blob                25d2042
before: removed-text count=1 (want 1); injected count=0 (want 0)
after : removed-text count=0 (want 0); injected count=1 (want 1)
mutated blob             e88355f70e8648f1e3d30147f0c82b7c3c157609
VERDICT ablation-mutated self-test exit=1       ← 14 cases red
  ✗ every declared PAIR field is present once per derived pair, prefixed by its index
  ✗ the SELECTION RULE is printed, not merely applied — two runs must be comparable on the rule too
  ✗ …and it is the one constant, so the printed rule cannot drift from the applied one
  ✗ the SELECTED carrier is named by id and by date
  ✗ ⭐ …with a BODY FINGERPRINT: the one field that tells "same ids, different bytes" apart
  ✗ ⭐ …and it MOVES when only the bytes move: same ids, same count, same newest, different verdict
  ✗ every REJECTED candidate is named, with the reason it is not the carrier
  ✗ …and a thread whose claims are all in the pool says THAT, rather than going quiet
  ✗ a claim that parses ZERO branches leaves NO carrier, and the block names that claim
  ✗ an UNREAD thread reads UNREAD, ⛔ never 0 rows
  ✗ the line READ from the carrier is stated — declared, near miss or nothing
  ✗ the PAIRING quotes the body line it was derived from
  ✗ …and the branch-name fallback names the head ref instead of quoting a line that does not exist
  ✗ the PR-BODY line is read and stated — ⛔ and stated as an INPUT, never as a limb
restored blob            25d2042   (HEAD 25d2042)
git diff HEAD --name-only: []
VERDICT ablation-restored self-test exit=0
```

Direction predicted before the run and observed: **turns red**. The
module is run directly from
source by `node scripts/pm/…` — no build and no `dist/` between the edit
and the run, so the
on-disk proof is the whole preflight.

⚠️ **A named gap, not a hidden one**: the battery drives the builder and
the renderer, and it
cannot see `main`'s **emission**. An ablation that deleted the two lines
in `main`'s `finally`
would come back green. What covers emission is the three live blocks
quoted above, taken on this
head across three different exits.

## Candidate cause, unproven — ⛔ not fixed here

Two readings taken while wiring the record. Neither is acted on in this
PR.

**1. On the blob all three 2026-09-13 runs ran, exit 4 was the
DETERMINISTIC answer for that
pair — so what is unexplained is the two 0s, not the 4.**

- The file's last change before those runs was `a5ed18ced`
(2026-09-12T06:05:25Z, "a key-INITIAL
clause-② line that QUOTES the spelling is not a declaration"); its next
change was
`4e3a496ba` at 2026-09-13T17:19:18Z, after all three runs. `a5ed18ced`'s
blob is
`aecbb2d86683eb908468fdacaac2ff53753f06ef` — the same blob PR objectstack-ai#18448's
body independently cites
  as "the exact blob the 2026-09-13 readings were taken from".
- The governing claim on card objectstack-ai#17425 at that moment was comment
`5650083758`
(2026-09-13T01:57:23Z). Its line 3 opens `Clause-②: no —` and then
quotes the spelling again
inside the same line. Run first-hand against **that historical blob's
own
`readClause2Line`**: `{"kind":"near-miss","reason":"describing"}`, and
`cardDeclaration` on a
one-claim thread reads `missing` — ⛔ not a declaration. Today's copy
reads it identically.
- A `--pair-json` document assembled from the REAL thread as it stood at
03:04:09Z (its 9
comments, both carriers' real label event streams) answers **exit 4,
MISPLACED** on this PR's
head, quoting the superseded `Clause-②: yes` and naming `5650083758` as
the correction target —
which is what the 03:04Z reviewer and the 02:53Z dev round both
reported.
- ⇒ The 4 is reproducible and mechanically explained. The 0s are not. ⭐
Exactly the difference
the record's `claim.selected` fingerprint and `claim.clause2-line` would
have shown, had the
  0-runs printed one.
- ⚠️ Limits of this reading: the historical module was exercised for
`readClause2Line` (self
contained) and `cardDeclaration` (which imports today's sibling
modules); the commit ordering
is read from a shallow checkout, corroborated by objectstack-ai#18448's independent
citation of the same blob.

**2. The comment read — the one the declaration limb depends on — is the
only read here with no
page discipline.** `readCardComments` issues ONE request,
`/issues/N/comments?per_page=100`, with
no `page=` ladder and no short-read check. `readCarrierEvents` and
`readPullFiles` both page to
exhaustion and answer `null` (UNJUDGED, never clean) when their cap is
hit, for the reason their
own docblocks state. A card thread past 100 comments therefore loses its
tail silently, and the
claim pool is built from whatever came back. Not the cause on objectstack-ai#17425 (7
comments at 02:53Z, 16
today), but it is a live fail-open in this reading. The record makes it
visible for the first
time: request `objectstack-ai#3` prints its row count, so a `(100 row(s))` on a
`per_page=100` request is now
readable. ⛔ Not fixed here; the seat files or re-scopes.

## Gates

Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`
from the worktree with no hand-fed path list; re-derived after rebasing
onto current `main`
(the derivation was STALE-TREE by 4 commits) — **identical command
list**. All 34 run at head
`f5773ce08`, exit codes captured redirect-then-`$?`:

```text
0 :: node scripts/check-adr-0087-registration.mjs --base origin/main
0 :: node scripts/check-adr-0087-registration.mjs --self-test
0 :: node scripts/check-changeset-no-major.mjs --base origin/main
0 :: node scripts/check-changeset-no-major.mjs --self-test
0 :: node scripts/check-ci-filter-parity.mjs
0 :: node scripts/check-closing-keyword-parity.mjs
0 :: node scripts/check-closing-keyword-parity.mjs --self-test
0 :: node scripts/check-comment-mask-corpus.mjs
0 :: node scripts/check-declaration-mirrors.mjs
0 :: node scripts/check-declaration-mirrors.mjs --self-test
0 :: node scripts/check-scripts-symbol-anchors.mjs
0 :: node scripts/check-scripts-symbol-anchors.mjs --self-test
0 :: node scripts/check-self-test-wired.mjs
0 :: node scripts/check-self-test-wired.mjs --self-test
0 :: node scripts/check-self-test-workflow-commands.mjs
0 :: node scripts/check-self-test-workflow-commands.mjs --self-test
0 :: node scripts/check-whole-set-label-write.mjs
0 :: node scripts/check-whole-set-label-write.mjs --self-test
0 :: node scripts/pm/bare-root-worklist.mjs --self-test
0 :: pnpm check:agent-test-spelling
0 :: pnpm check:bash32-floor
0 :: pnpm check:changeset-gate-self-tests
0 :: pnpm check:cli-command-ids
0 :: pnpm check:cross-package-test-inputs
0 :: pnpm check:driver-memory-census
0 :: pnpm check:entry-guard
0 :: pnpm check:nul-bytes
0 :: pnpm check:parse-guard
0 :: pnpm check:pm-clause2-carriers
0 :: pnpm check:pnpm-filter-targets
0 :: pnpm check:ratchet-remedy-authority
0 :: pnpm check:refd-timer-probe
0 :: pnpm check:watch-hint-literal
0 :: pnpm check:pm-dispatch-gates
```

Reconciled: `dispatch-gates --ran` ⇒ **34 derived, 34 run, 0
NOT-MEASURED, 0 UNRUN**.

Repo-wide `pnpm lint` (`eslint . --no-inline-config`) at `f5773ce08`:
**exit 0**.
`grep -naP` for control bytes over the changed file: no hits.

⛔ Outside these 34, as the derivation itself prints: 53 artifact-roster
families, 11
wide-population families, 7 pending-changeset families, 1 path-scheduled
CI job and the
always-runs tail. Their absence here is not a clearance.

## Acceptance notes

Out of scope, noted and ⛔ not filed:

- The read-path report and the input record now also print on the
`--pair-json` **usage
refusals** (a missing file, a non-JSON document, a board conflict),
because everything past the
board resolution moved inside one `try`/`finally`. One extra stderr line
on those paths, in the
direction the file's own header argues for. Carrier: whoever next edits
`main`.
- `main`'s `--pair` value is parsed in two places now (once for
`run.mode`, once for the pair
itself). Both read the same argv through `flagIndex`; a reader may
prefer one. Carrier: whoever
  next edits `main`.

---

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Gqi43smmqjJ5sUrhfoPeKu

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ast 100 is UNJUDGED rather than a truncated claim pool (objectstack-ai#18799)

Fixes objectstack-ai#18683

Clause-②: no

## The defect

`scripts/pm/check-clause2-carriers.mjs` read a card's comment thread
with ONE request — `/issues/{n}/comments?per_page=100`, no `page=`
ladder, no short-read check — while the two sibling list reads in the
same file paged to a declared cap and answered `null` (UNJUDGED, never
clean) when they hit it. One file, two OPPOSITE defaults on "I did not
read everything", and the fail-OPEN one was the read that arbitrates
OWNERSHIP: the governing-claim pool, its membership, and the `Clause-②`
declaration read out of it all come from those rows. A thread past 100
comments handed the pool its first page and nothing said the tail had
been dropped, so a claim written past row 100 was not superseded — it
was never a candidate — and a superseded carrier governed in its place.

## The before-reading, on a 101-row fixture

Driven end to end through the real CLI against a stubbed board
(`--pair`, no network), on `main` `d9ba33df4c` (script blob
`d753e2a8cf06d8f72c436e0a1917b2fecb8be813`). Two fixtures, both 101
rows, both differing from a complete thread only past the page boundary.

| fixture | BEFORE (`main`) | AFTER (this PR) |
|---|---|---|
| 100 claim-free rows, the 101st the only `Claim:` | `card-comments: 100
row(s)` · `claim.selected: none — no comment on this thread carries a
line beginning \`Claim:\`` · **exit 4, row C2 `absent`** |
`card-comments: 101 row(s)` · the 101st claim is the pool ·
`claim.clause2-line: DECLARED \`no\`` · **exit 0** |
| row 1 an older `Claim:` declaring `yes`, the 101st a newer one
declaring `no` | `claim.clause2-line: DECLARED \`yes\`` from the
SUPERSEDED carrier, which is not even listed as rejected · **exit 4, row
C3** | the newer claim governs, the older is listed REJECTED/SUPERSEDED
· `DECLARED \`no\`` · **exit 0** |

The second row is the fail-OPEN direction stated as a measurement: one
thread, two readings, and they disagree on the declaration itself.

## The ladder, and the cap

All three list reads now go through one `pagedListRead` helper — it
pages to a declared cap, stops on the FIRST short page (no wasted
request), and on the cap files the one shared `pageCapNote` sentence and
answers `null`. `readCarrierEvents` (`EVENT_PAGE_CAP` 10) and
`readPullFiles` (`FILE_PAGE_CAP` 3) keep their caps to the number; what
they gain is that the third read can no longer hold a different default.

`COMMENT_PAGE_CAP` is **10** pages = 1,000 comments. Sized on this
board, read 2026-09-17 off the open-issue list rows (550 rows listed,
cross-checked against `open_issues_count` = 550):

- longest open thread of any kind: seat post objectstack-ai#6015 at **895** comments —
nine pages;
- next four: objectstack-ai#12708 at 365, objectstack-ai#6023 at 241, objectstack-ai#6017 at 206, objectstack-ai#6024 at 187;
seat post objectstack-ai#7623 at 71;
- longest thread carrying a queue label: objectstack-ai#13799 at **117** (`pm:queue`,
p2, unassigned);
- longest card in the clause-② population (28 pairs the sweep derived
that day): objectstack-ai#17534 at **14**.

So ten pages clears the whole board today with a page to spare, and it
is the same ten `EVENT_PAGE_CAP` uses — a reader comparing two caps in
one file should have to remember one number.

## The input record

The diagnosis key stays `comments`, so every sentence already keyed to
it still finds its diagnosis. Two declared fields are added to
`INPUT_RECORD_PAIR_FIELDS`, one per thread this file reads:

```
pair.1.card-comments: 101 row(s)
pair.1.card-comment-pages: 2 of 10 page(s) requested — the ladder stopped on a SHORT page, so the thread is COMPLETE
pair.1.pr-comment-pages: 1 of 10 page(s) requested — the ladder stopped on a SHORT page, so the thread is COMPLETE
```

and, when the cap is what stopped the read:

```
pair.1.card-comment-pages: CAPPED — 10 of 10 page(s) of 100 comments each were requested and EVERY ONE came back full, so the tail is past the cap and the thread is UNREAD (UNJUDGED) — ⛔ never a truncated pool, ⛔ never a clean reading
```

A thread of exactly 100 rows and a thread whose tail was dropped are the
same `100 row(s)` in every other line the block prints; they differ
here, because the complete one stopped on a short page and the truncated
one did not stop at all. The request ledger PR objectstack-ai#18681 added shows the
same ladder from the other side — request objectstack-ai#3 is now
`…/comments?per_page=100&page=1` and objectstack-ai#4 is `&page=2`.

## The pins

A new `--self-test` battery, `objectstack-ai#18683: the card-comment read pages to a
cap — past 100 is UNJUDGED, ⛔ never a truncated pool`, 27 cases,
declared in `SELF_TEST_BATTERIES` with the roster floor raised 29 → 30.
It drives the ladder with an offline page server that reproduces
GitHub's own semantics and counts the requests; ⛔ no network. What it
holds: the 101st claim ENTERS the pool and GOVERNS, and its line is what
the limb reads; the same thread cut at 100 reads `absent` (the CONTROL —
the reading the un-paged read produced); the newer claim past the
boundary supersedes the older one inside it, and cut at 100 the
superseded carrier's `yes` is what the limb reads; a capped read is
`null`, which is neither `missing` nor `absent` nor a carrier but
`unreadable`; the ladder stops on the first short page (2 requests for
101 rows, 1 for a short thread, 2 for exactly 100 — a full page is
indistinguishable from a finished one); a page that came back unread
ends the ladder and the record says the cap was NOT what stopped it; the
input record declares and prints both ladder fields; the sibling caps
are untouched; and all three reads render ONE cap sentence.

## The census, and the triage's upgrade probe

Report-only, no state write. Over the 550 open rows (521 issues, 29 PRs)
read on 2026-09-17:

- open `pm:queue` / `pm:dispatched` cards: **274**, of which **1**
exceeds 100 comments — objectstack-ai#13799 at 117;
- all open issues over 100 comments: **8** — objectstack-ai#6015 (895), objectstack-ai#12708 (365),
objectstack-ai#6023 (241), objectstack-ai#6017 (206), objectstack-ai#6024 (187), objectstack-ai#6021 (145), objectstack-ai#6367 (127), objectstack-ai#13799
(117). Seven are `pm:seat` posts;
- open PRs over 100 comments: **0**; the longest is objectstack-ai#18638 at 10.

**The upgrade probe's result: the condition is NOT met today.** The
clause-② population is what a `--pair`/sweep derivation actually pairs,
not what carries a queue label: the sweep derived **28 pairs from 29
open PRs**, and the longest card thread among them is **14** rows
(objectstack-ai#17534). The two open PRs that mention a 100+-comment card in prose —
objectstack-ai#18786 (objectstack-ai#6015, objectstack-ai#7623) and objectstack-ai#18765 (objectstack-ai#6024) — deliver objectstack-ai#18693 and objectstack-ai#18652
respectively, both under 10 comments; driven live before and after, both
answer exit 0 with an identical pair reading. So no recorded `--pair`
verdict on this board today was taken on a truncated pool, and the
triage's p1 condition (「找到任一进入条款②认领池、评论数 > 100 的卡并驱动一次」) has no live
instance to drive. The exposure is one PR away rather than realised:
objectstack-ai#13799 is `pm:queue` at 117 and enters the population the moment a PR
delivers it.

The cost is unchanged by the ladder, measured on the same board: **64
reads for 28 pairs, before and after**, because every live thread fits
one page and the ladder stops on a short page. The live `--pair 18765`
input records differ in exactly three lines — the two request paths
gaining `&page=1`, and the two new ladder fields.

⚠️ One thing the two full sweeps do NOT compare: the sweep's finding
COUNT moved 4 → 3 between them, and that is the board, not this diff.
`needs:contract-review` was hung on PR objectstack-ai#18792 at `2026-09-17T21:04:46Z`,
between the two runs, closing the C1 split on objectstack-ai#18792 / objectstack-ai#17541 on its
own. The controlled A/B is the `--pair 18765` diff above.

## The ablation

Two legs, each from the COMMITTED fix, each proving the mutation reached
disk by blob hash and occurrence count before reading any result, each
restored under a `trap` with `git checkout HEAD --` and verified by hash
and an empty `git diff HEAD`. HEAD blob
`ccd5ad7c9a00fe703d644be261a24f1ed847915a`.

| leg | mutation | blob after | self-test |
|---|---|---|---|
| A — the ENTRY side | `COMMENT_PAGE_CAP` 10 → 1 |
`1a0a952d07748101937420006b9475042d93a5cb` | **exit 1, 11 of 865 red** —
the 101st-claim pins, the superseding pins, the request-count pins, the
input-record pin |
| B — the UNJUDGED side | the cap branch returns the pages that DID
arrive (the pre-fix fail-OPEN default) |
`c176dfe7e54e7de6bc45737487841a346f509b79` | **exit 1, 3 of 865 red** —
a capped read is no longer `null`, no longer `unreadable`, and files no
cap sentence |

Every red in both legs belongs to the new battery; nothing pre-existing
went red in either. A third, unplanned reading came for free: leg B's
first attempt was a `perl -0pi` substitution whose anchor contained a
`/`, so the edit silently did nothing — the on-disk proof refused it
with `ABLATION VOID: the edit did not reach disk` instead of reporting a
green as a measurement.

## Self-test

```
✓ check-clause2-carriers self-test: 865 cases pass
```

838 before, 865 after — the 27 the new battery registers, which is what
its floor pins.

## Derived gates

`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, no hand-fed path list, re-derived after
each `origin/main` merge (identical list both times). All 34 run at head
`993cb89e18`, each exit code captured by redirect-then-`$?`:

```
node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0
node scripts/check-changeset-no-major.mjs --self-test :: exit 0
node scripts/check-ci-filter-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-declaration-mirrors.mjs :: exit 0
node scripts/check-declaration-mirrors.mjs --self-test :: exit 0
node scripts/check-scripts-symbol-anchors.mjs :: exit 0
node scripts/check-scripts-symbol-anchors.mjs --self-test :: exit 0
node scripts/check-self-test-wired.mjs :: exit 0
node scripts/check-self-test-wired.mjs --self-test :: exit 0
node scripts/check-self-test-workflow-commands.mjs :: exit 0
node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0
node scripts/check-whole-set-label-write.mjs :: exit 0
node scripts/check-whole-set-label-write.mjs --self-test :: exit 0
node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0
pnpm check:agent-test-spelling :: exit 0
pnpm check:bash32-floor :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:cli-command-ids :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:entry-guard :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:parse-guard :: exit 0
pnpm check:pm-clause2-carriers :: exit 0
pnpm check:pm-dispatch-gates :: exit 0
pnpm check:pnpm-filter-targets :: exit 0
pnpm check:ratchet-remedy-authority :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm lint :: exit 0
```

`--ran` reconciles 34 derived / 34 run / 0 UNRUN. `pnpm
check:pm-dispatch-gates` was run detached to a file — 1,788 cases,
748.6s on this box — and waited on in the foreground rather than under a
timeout, so it is a measurement and not a SIGTERM.

## Out of scope, deliberately

objectstack-ai#18764 (a decorated `**Claim:**` never enters the pool — the ENTRY side)
was read and NOT folded in: this card is WHICH rows reach the reader,
not what the reader does with them, and the two repairs touch different
lines. `claimRetractions` (PR objectstack-ai#18770, the EXIT side) was read for the
words it uses and not touched. The header's request-budget paragraph is
amended in the same commit, because it stated "2 reads per card" as a
fact and the thread is now a ladder — a cost statement that stopped
being true is the shape this file exists against.

`skip-changeset`: `scripts/pm/**` ships in no package's `files[]`, so
nothing published moves.

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ferences, TOC the seven long reference files, trim data's description (objectstack-ai#19738)

Fixes objectstack-ai#19715
Clause-②: no

## 维护者速读(草稿)

**改了什么** — 四个超过 500 行的已发布 `SKILL.md`(platform 1223 → 487 · automation
961 → 448 · data 854 → 469 · upgrade 600 →
488)按标题边界切分:每个被移走的段落与代码块**逐字节**落到该技能自己的 `references/` 下(新增 9
个参考文件),原位只留一行指针;七个超过 300 行的参考文件顶部各加 5 行目录(锚到已有标题);`objectstack-data` 的
`description` 压到 689 字符,触发短语与排除句原样保留、只删括号枚举。零删内容:20 个移动块中 17 个字节相同,3
个仅改了 10 处相对链接路径(逐条列在下文)。

**为什么改** — 决策批次 objectstack-ai#213 第 1 项,维护者裁决「1 2 4 同意」:超长 SKILL.md 会被 `head`
预读截断,示例与运维尾段读起来像第二个技能;切成「路由 + 规则」后入口文件只装规则、决策表与指针,长示例按需跟指针再读。

**风险与代价(含回滚)** — ① 直接读 `SKILL.md` 单文件时,105 个 eval 断言词里有 18 个(从 103 降到
85)现在只经指针可达;整棵技能树(SKILL.md + rules + references)的读数 105/105
前后不变,方法与局限见下文。② 为了到 500 行,platform 的第二部分(插件开发)与 automation
的「状态机与审批」整段下沉,其中含少量决策表——它们仍在树里,入口页保留指向 `rules/*.md` 的路由行。③
三个门禁脚本的数据行跟着文本走(token 天花板行、role-word 基线两行搬家、scaffold 政策的载体路径),没有新增门禁。④
令牌总量 +2058(指针行 + 目录行),四个入口行已按落地值下锁,七个目录文件中 4 行按裁决上调、3 行有余量按落地值下锁。回滚:整个
PR 一次 revert 即可,没有生成物或状态迁移。

**席位意见** — **建议批准并合并,但先看一眼「Sections that left SKILL.md」。**
裁决的八条形状逐条核过:零删内容(20 个移动块 17 个字节相同、3 个只改了 8 处相对链接;席位另做了四个技能的整行多重集比对,除链接与
description 外没有任何一行离开树)、四个入口文件全部 ≤ 500、data 的 description 689
字且触发短语与排除句原文保留、七个长参考文件各 5 行目录且锚点全部可达、净 +77 ≤ +80、无新门禁、changeset 按 objectstack-ai#19721
惯例。evals 读数按机械法前后树列 105 / 105 不变;入口页单读 103 → 85,离开的 18 个词都在指针可达处 ——
这是切分的代价,不是丢失。token 棘轮四行入口下锁、九个新文件零余量钉住、四行目录文件按裁决引文上调。第 0 轮 CI 红是本 PR
的:`packages/rest` 的仓级测试钉住 automation 入口页必须写出状态内省路由,切分把唯一拼写搬走了;第 1
轮把拼写放回指针行(净 0 行,不改测试),席位在补丁头上重跑该测试 8 / 8 绿。留给你的一个判断:为了到 500 行,platform
的插件开发与运维两大部分、automation 的状态机与审批、data 的 seeds / 字段组 / lint 整段下沉,其中含少量决策表
—— 它们仍在树里、入口页留有路由行;如认为某段必须留在入口页,点名即可,dev 按行预算换一段下沉。复核记录:PR 评论
5786766593(PASS,在席按服务档渲染);ACCEPT 在卡 objectstack-ai#19715。CI 读数:32 latest-per-name
check runs — 25 success, 7 skipped, 0 in progress, 0 other。

**你要做的** — 这是 Tier H(`skills/**`)受管面:确认「哪些规则段可以离开入口页」符合你的意图(见「Sections
that left SKILL.md」),然后手工合并;如认为某段必须留在入口页,请点名,我按行预算再换一段下沉。

---

## What this does

The four published `SKILL.md` files over 500 lines are split at heading
boundaries into routing + rules. Every moved paragraph and code block
lands **verbatim** under that skill's `references/` (nine new files)
with a one-line pointer left where it was; the seven reference files
over 300 lines gain a 5-line table of contents anchored to their
existing headings; `objectstack-data`'s `description` shrinks to 689
characters with the same trigger phrases and exclusion sentence. The
token ratchet re-locks the four entry rows at their landed counts, pins
the nine new files at theirs, and raises the four TOC'd rows that
carried no headroom by exactly the added tokens under the ruling.
`skills/README.md` and `content/docs/ai/skills-reference.mdx` are
regenerated from the trimmed frontmatter by `gen:skill-docs`.

Premise re-measured on `origin/main` `106d4c8dd` before editing: the
four line counts (1223 · 961 · 854 · 600) hold; the seven files carried
no TOC; data's `description` measured **1003** characters by a YAML
folded-scalar parse (the card's 1,023 was a different counting; either
way it sat against the 1,024 cap). `premise_still_valid: true`.

## Readings after (head `011dfd603`)

| File | Before | After |
|:--|--:|--:|
| `skills/objectstack-platform/SKILL.md` | 1223 | **487** |
| `skills/objectstack-automation/SKILL.md` | 961 | **448** |
| `skills/objectstack-data/SKILL.md` | 854 | **469** |
| `skills/objectstack-upgrade/SKILL.md` | 600 | **488** |
| data `description` (YAML folded, trailing newline excluded) | 1003
chars | **689 chars** |
| net lines across `skills/**` (tracked +77 / −1781, new files +1781) |
— | **+77** (budget ≤ +80) |

## Diff proof — every moved block (source lines at `106d4c8dd` →
destination), verified byte-for-byte against the base blob

| # | Source (lines, count) | Heading(s) | Destination (lines) | Bytes |
|:-:|:--|:--|:--|:--|
| 1 | platform `SKILL.md` 39–68 (30) | `### Minimal Example` |
`references/bootstrap.md` 3–32 | verbatim |
| 2 | platform 116–157 (42) | `### Map Format (Key → Name)` · `###
Barrel Import Pattern` | `references/bootstrap.md` 34–75 | verbatim |
| 3 | platform 272–280 (9) | `### Scaffolding Command` |
`references/bootstrap.md` 77–85 | verbatim |
| 4 | platform 498–527 (30) | `### Plugin Loading Order Matters` · `###
Programmatic Bootstrap (Without CLI)` | `references/bootstrap.md` 87–116
| verbatim |
| 5 | platform 531–542 (12) | `## Multi-App Composition` |
`references/bootstrap.md` 118–129 | verbatim |
| 6 | platform 585–644 (60) | `## Complete Working Example` |
`references/bootstrap.md` 131–190 | verbatim |
| 7 | platform 649–1007 (359) | `# Part 2 — Plugin Development & Kernel
Extension` (whole) | `references/plugin-development.md` 1–359 | 6 link
paths re-pathed, else verbatim |
| 8 | platform 1012–1213 (202) | `# Part 3 — Operations: CLI, Testing,
Deployment` (whole) | `references/operations.md` 1–202 | verbatim |
| 9 | automation 143–209 (67) | `### Flow Example — Auto-Escalate
Overdue Cases` | `references/examples-flows.md` 3–69 | verbatim |
| 10 | automation 378–760 (383) | `## State Machines & Approvals`
(whole) | `references/state-machines-and-approvals.md` 1–383 | verbatim
|
| 11 | automation 851–916 (66) | `### Time-relative triggers — scheduled
per-record date sweep` | `references/examples-flows.md` 71–136 |
verbatim |
| 12 | data 184–230 (47) | `## Field Groups (MVP)` |
`references/examples-objects.md` 3–49 | verbatim |
| 13 | data 300–359 (60) | `## Quick-Start Template` |
`references/examples-objects.md` 51–110 | 1 link path re-pathed |
| 14 | data 499–525 (27) | `### Lifecycle Hooks` |
`references/examples-objects.md` 112–138 | 1 link path re-pathed |
| 15 | data 565–622 (58) | `## Metadata Protection (\`protection\`)` |
`references/examples-objects.md` 140–197 | verbatim |
| 16 | data 626–767 (142) | `## Seed Data & Fixtures (\`defineSeed()\`)`
(whole) | `references/seeds.md` 1–142 | verbatim |
| 17 | data 771–823 (53) | `## Linting & Generation Quality` |
`references/lint-rules.md` 1–53 | verbatim |
| 18 | upgrade 269–301 (33) | `### 2.3 A worked R1 — the retired
field-mapping \`transform\`` | `references/examples-upgrade.md` 3–35 |
verbatim |
| 19 | upgrade 449–496 (48) | `### 3.4 The report — the human half` |
`references/examples-upgrade.md` 37–84 | verbatim |
| 20 | upgrade 540–573 (34) | `## The v17-canonical shapes, compiled` |
`references/examples-upgrade.md` 86–119 | verbatim |

Method: for each row the base bytes (`git show 106d4c8:path`, the
listed lines) were hashed against the destination's listed lines — 17
rows identical, 3 rows differ only on the link-path lines below. The
upgrade `a`-tag anchors (`decide-alone-or-ask`, `reverse-check`) stayed
in `SKILL.md` because they sit just outside the moved ranges. The four
new multi-block files carry a one-line H1; the five single-block files
begin with the moved heading itself.

**The only non-verbatim bytes — 10 link-path retargets** (a relative
link crossing a move boundary would otherwise dangle):

- platform `SKILL.md` (staying text): `#verify-your-work`,
`#ports--networking`, `#part-3--operations-cli-testing-deployment` →
`./references/operations.md#…` (3)
- `references/plugin-development.md` (moved text, lines
656/657/658/738/760/791 at base): `./rules/plugin-lifecycle.md` →
`../rules/…` (2), `./rules/service-registry.md` → `../rules/…` (2),
`./references/plugin-hooks.md` → `./plugin-hooks.md` (2),
`../objectstack-data/SKILL.md` → `../../objectstack-data/SKILL.md` (1)
- data `SKILL.md` (staying text): `#field-groups-mvp` →
`./references/examples-objects.md#field-groups-mvp` (1)
- `references/examples-objects.md` (moved text, base lines 359 and 523):
`./rules/indexing.md` → `../rules/indexing.md` (1),
`./references/data-hooks.md` → `./data-hooks.md` (1)

Every intra-file anchor in the 15 touched markdown files resolves
(github-slugger, the repo's slug authority; 100 anchors, 0 unresolved)
and all 104 relative links under `skills/**` resolve to existing files
(the two `./crm_index.md` / `./crm_user_guide.md` spellings in
`objectstack-ui/rules/pages.md` are illustrative example text on the
base, untouched).

## Sections that left SKILL.md, and why the route had to widen

The card's route (platform's ops tail + the two 60-line complete
examples) reaches 894 lines on platform — the fenced code in the four
files totals 462 / 310 / 223 / 162 lines against the 723 / 461 / 354 /
100 that had to go, so examples alone cannot reach 500 on three of the
four. The cut therefore also moves whole self-contained parts: platform
Part 2 (plugin development, which already has its own `rules/*.md` and
`references/plugin-hooks.md` — the entry keeps a routing line to them),
automation's State Machines & Approvals, data's Seed Data & Fixtures,
Field Groups and Linting. Decision tables that now live behind a
pointer: platform's ObjectKernel vs LiteKernel, Plugin Loading Order,
Well-Known Plugin Names, MetadataPlugin boundary, Feature Flags, the ops
tables; automation's Approver Types, Node Config, Branching, Best
Practices and the state-machine rule; data's seed tables and the
lint-rule table. Every pointer names the destination and the moved
headings (heading names only — no paraphrase). If the maintainer wants a
named section back in the entry, it costs its line count against the 500
ceiling; say which and I swap.

## Evals reading (card item 5) — method, before / after, limit

There is no executable runner in this repo: the fixtures
`skills/*/evals/*.json` are read by
`scripts/check-skills-token-ratchet.mjs` for pricing only (no script
consumes `must_contain`). The reading is therefore the mechanical one
the dispatch names: for every eval of the four skills, each
`must_contain` term's presence (substring, case as written) in that
skill's `SKILL.md` alone and in the skill tree (`SKILL.md` + `rules/**`
+ `references/**`), on `106d4c8dd` and on this head. Script:
`eval-reading.mjs` (in the scratchpad, not committed).

| Skill | SKILL.md alone, before | SKILL.md alone, after | Tree, before
| Tree, after |
|:--|--:|--:|--:|--:|
| objectstack-platform | 24/24 | 18/24 | 24/24 | 24/24 |
| objectstack-automation | 26/26 | 20/26 | 26/26 | 26/26 |
| objectstack-data | 35/37 | 31/37 | 37/37 | 37/37 |
| objectstack-upgrade | 18/18 | 16/18 | 18/18 | 18/18 |
| **total** | **103/105** | **85/105** | **105/105** | **105/105** |

The tree column — the reading of record for a skill whose entry routes
to references — is unchanged for all 105 terms. The 18 terms that left
`SKILL.md` alone, each with the pointer on the eval prompt's path (the
two data terms already absent on the base, `schemaMode` and `type:
'secret'`, live in `rules/datasources.md` and `rules/security.md` and
are untouched):

| Eval | Term | Now in | Pointer in SKILL.md |
|:--|:--|:--|:--|
| platform objectstack-ai#2 (audit plugin) | `registerService`, `ctx.hook(`,
`metadata:reloaded`, `init(`, `destroy` |
`references/plugin-development.md` (+ `rules/plugin-lifecycle.md`,
`references/plugin-hooks.md`) | the Part 2 pointer, which also names
`rules/plugin-lifecycle.md` · `rules/service-registry.md` ·
`references/plugin-hooks.md` |
| platform objectstack-ai#6 (production deploy) | `OS_TRUSTED_ORIGINS` |
`references/operations.md` | the Part 3 pointer (names Ports &
networking · Deployment targets) |
| automation objectstack-ai#1 (nightly sweep) | `defineFlow`, `type: 'schedule'` |
`references/examples-flows.md` | the Flow Example pointer; the Flow
Types table row `schedule` stays in the entry |
| automation objectstack-ai#3 (approval + revise) | `type: 'approval'`, `type:
'back'`, `maxRevisions` | `references/state-machines-and-approvals.md` |
the State Machines & Approvals pointer (names Send-back for revision ·
Node Config); `approval_revise` and `revise` stay in the entry |
| automation objectstack-ai#5 (function step) | `timeoutMs` |
`references/examples-flows.md` | the Flow Example pointer |
| data objectstack-ai#4 (seeds) | `env:`, `daysFromNow` | `references/seeds.md` | the
Seed Data pointer (names Dynamic values (CEL)); `defineSeed`,
`externalId`, `upsert` stay in the entry |
| data objectstack-ai#2 (invoice lines) | `deleteBehavior`, `inlineEdit` |
`rules/relationships.md`, `rules/field-types.md`,
`references/lint-rules.md` | the Relationship Patterns table's link to
`rules/relationships.md` (unchanged) |
| upgrade objectstack-ai#2 (conditionalRequired) | `requiredWhen` |
`references/examples-upgrade.md` | the v17-canonical shapes pointer |
| upgrade objectstack-ai#3 (transform residue) | `never executed` |
`references/examples-upgrade.md` | the worked R1 pointer |

Limit of the method: substring presence is not a graded run — it cannot
say whether an agent given the prompt would follow the pointer. It is
the reading the card can have today; no runner was built (item 6). No
assertion was tuned.

## Token ratchet (`scripts/check-skills-token-ratchet.mjs`, convention
ceil(utf8 bytes / 4))

Bundle total 152,338 → **154,396** (+2,058: the 20 pointer lines, seven
5-line TOCs, four new-file titles, minus the description trim);
ratcheted ceiling sum 157,621 → 154,915 (the four entry re-locks bank
their shrink). Gate and `--self-test` green on this head (54 authored
files within ceilings, 65 self-test cases).

| Row | Ceiling before → after | Kind |
|:--|:--|:--|
| `objectstack-platform/SKILL.md` | 12984 → 5833 | re-lock at landed
count |
| `objectstack-automation/SKILL.md` | 12768 → 5762 | re-lock |
| `objectstack-data/SKILL.md` | 10009 → 6128 | re-lock |
| `objectstack-upgrade/SKILL.md` | 8333 → 6193 | re-lock |
| `objectstack-data/rules/field-types.md` | 3032 → 3158 | **raise** +126
(TOC; zero headroom) — ruling cited in the row |
| `objectstack-ui/rules/dashboards.md` | 6090 → 6252 | **raise** +162
(TOC; zero headroom) |
| `objectstack-ui/rules/list-views.md` | 3011 → 3141 | **raise** +130
(TOC +132, 2 absorbed) |
| `objectstack-ui/rules/pages.md` | 5501 → 5692 | **raise** +191 (TOC;
zero headroom) |
| `objectstack-data/references/data-hooks.md` | 12611 → 10066 | re-lock
(TOC +182 landed inside 2,727 headroom) |
| `objectstack-data/rules/relationships.md` | 3778 → 3676 | re-lock (TOC
+154 inside 256 headroom) |
| `objectstack-data/rules/validation.md` | 3109 → 2756 | re-lock (TOC
+155 inside 508 headroom) |
| 9 new rows | `examples-flows` 1436 · `state-machines-and-approvals`
5569 · `examples-objects` 1779 · `lint-rules` 970 · `seeds` 1299 ·
`bootstrap` 1351 · `operations` 3125 · `plugin-development` 3137 ·
`examples-upgrade` 1197 | pinned AT landed count, zero headroom |

The dispatch's assumption that all seven TOC'd rows sat at zero headroom
was false for four of them; those four are re-locked (a lowering, always
legitimate) rather than raised.

## Other gate data that followed the moved text (no new gate, ratchet or
check script)

- `scripts/role-word-baseline.json`: the automation entry's one `role`
occurrence (the Approver Types row) and the upgrade entry's one (`role:`
in the v17 agent shape) moved with their sections; the two ledger rows
moved with them. 44 files / 123 occurrences before and after — a
relocation, not an expansion; `check:role-word` green.
- `scripts/sync-scaffold-emission-policy.mjs`: the published-restatement
row that locates the Complete Working Example's `package.json` fence by
heading now points at `references/bootstrap.md`, where the fence lives;
`check:scaffold-emission-policy` green (2 carriers).
- Generator-owned files: `check:skill-refs` stays green without
regeneration — `build-skill-references.ts` owns only `_index.md`,
`*.zod.ts` and subfolders under `references/`, so the new top-level
hand-written files are inputs it tolerates; `_index.md` lists Zod
schemas only. `gen:skill-docs` regenerated `skills/README.md` and
`content/docs/ai/skills-reference.mdx` (the one file outside `skills/**`
and the gate scripts).
- `check-skill-identifier-liveness` leg-2 bindings (`## Seed Data` on
platform, `### Access depth …` on data) stayed in their entries.

## Gates (captured exit before any pipe; union derived by `node
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` on `011dfd603`, 70 families, `--ran` reconciles 70/70 with
exit codes)

All 70 derived families exit 0 on `011dfd603`, plus `pnpm --filter
@objectstack/spec check:generated` (15 artifacts up to date after the
`origin/main` merge). The ones that read a build were run after `pnpm
--filter '@objectstack/lint...' build` and `pnpm --filter
'@objectstack/client-react...' build` under the shared verify lock:
`check:skill-examples` — "258 prose examples type-check across 3
surface(s)" (the moved os:check blocks included); `check:docs`;
`check:docs-transcript-drift`; the two `@objectstack/lint` doc checks.
Highlights: `check:skills-token-ratchet` + self-test ✓ ·
`check:skill-docs` ✓ · `check:skill-refs` ✓ ·
`check:skill-identifier-liveness` ✓ · `check:skill-top-level-keys` ✓ ·
`check:skill-compatibility` ✓ · `check:skill-frame-sync` ✓ ·
`check:doc-authoring` ✓ · `check:role-word` ✓ ·
`check:scaffold-emission-policy` ✓ · `check:nul-bytes` ✓ ·
`check:pm-dispatch-gates` (1905 cases) ✓ · `bare-root-worklist
--self-test` ✓ (the `skills/*/references/**` liveness/precision pin
holds over the enlarged population). `pnpm lint` (repo-wide eslint) is
CI's run: the diff touches no `.ts`/`.js` source, only markdown, one
JSON ledger and two `.mjs` gate scripts whose own self-tests ran green.

Branch merged `origin/main` `170fd836a` (three spec commits, no overlap
with this diff) before opening; `packages/spec` was rebuilt after the
merge.

## Changeset

`skip-changeset`, per the last merged `skills/**` PR (objectstack-ai#19721:
`documentation` · `skip-changeset`, Clause-②: no) and measured: no
workspace package's `files[]` ships `skills/` (positive control:
`packages/cli` ships `bin`). `Clause-②: no`. Tier H — draft, the
maintainer merges by hand.

## Acceptance notes

- Two data eval terms were already reachable only through `rules/` on
the base (`schemaMode`, `type: 'secret'`) — noted, not a card.
- `objectstack-ui/rules/pages.md` lines 356/448 carry
`./crm_index.md`-style example links that resolve to nothing; they are
illustrative package-docs text inside the Docs section, untouched here —
noted, not a card.

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 30, 2026
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…lumns are the inline grid column contract, and the showcase Tasks grid binds by name (objectstack-ai#21142) (objectstack-ai#21244)

Fixes objectstack-ai#21142

Clause-②: yes

Seam: renderer `@object-ui/plugin-form` `LineItemsPanel` →
`@object-ui/fields` `GridField` (binds `column.name`) ← producer:
objectstack showcase `record:line_items` `columns[].field`

The showcase project page's Tasks grid keyed all five of its columns
`field`. The line-items grid binds a column by `name`, so every cell
rendered empty. Nothing refused it: `record:line_items` had no
`ComponentPropsMap` row, so the component-props gate skipped its props
bag as unregistered. This PR fixes the producer and adds the row (the
claim's call, per triage `5929028089`). From here on a `field`-keyed
column is refused at authoring, with the rename to `name`.

## What changed

- **`examples/app-showcase/src/ui/pages/project-detail.page.ts`**: the
five columns are keyed `name` (`title`, `status`, `priority`,
`estimate_hours`, `due_date`). Nothing else on the page moved.
- **`packages/spec/src/ui/component.zod.ts`**: new row
`ComponentPropsMap['record:line_items']` = `RecordLineItemsProps`, a
strict shape of the fifteen keys objectui's `LineItemsPanel` reads
(measured below):
- `relationshipField` is required, and so is `columns` (at least one).
Nothing on this panel derives either one.
- `childObject` is optional, because the component-level `dataSource`
binding can supply it.
- `filter`, `sort` and `limit` take the declarations every sibling door
takes: the ViewFilterRule array, the SortItem array and a positive
integer.
- The keys it shares with an `object-master-detail-form` detail entry
take that entry's types and alias table.
- The four entry keys this block does not read (`addLabel`, `sortField`,
`formFields`, `inlineMode`) are refused with a `guidance` reason.
  - New types `RecordLineItemsProps` and `RecordLineItemsPropsParsed`.
- **`columns` IS `InlineGridColumnSchema`**, by reference and not a copy
(Zone 2 objectstack-ai#4: same shape, see below). The retired `field` spelling is
refused by name with the prescription naming `name`. One carrier
difference is stated in the `describe()`: this panel does not hydrate a
column from the child field. For the same reason, `defineStack`'s
identity-only check (`collectHydratedInlineColumnErrors`) is
deliberately NOT extended to this block, and a control pin holds that.
- **`packages/spec/src/ui/component-type-vocabulary.ts`**:
`record:line_items` leaves `STRING_ARM_REGISTERED_TYPES`, which is now
empty. The export stays, and its docblock records why it is empty. The
type stays KNOWN through its row.
- **ADR-0087**: new D3 semantic entry
`ui-record-line-items-props-closed`, plus a step-18 rationale fragment
(order 57). `gen:migration-registry` regenerated the registry. No D2
conversion: page-component `properties` is not parsed on the save or
load path, and the census found one producer, respelled here.
- **Pins flipped / added**:
- `validate-component-props.test.ts`: `record:line_items` leaves the
unregistered-skip `it.each`. A new suite asserts the `field`-keyed
columns fire `component-props-unknown-key` at
`...properties.columns.N.field`, with `component-props-invalid` at
`...columns.N.name`. The `name`-keyed control is silent.
- `component-type-vocabulary.test.ts`: known through the row, not on the
ledger, not an enum member.
- `inline-grid-column-carriers.test.ts`: a fourth-carrier section
covering identity of the column element, the `field` refusal (code
`unrecognized_keys` at path `['columns', 0]`), the currency `scale`
refusal, the bogus key, `relationshipField` and `columns` required,
`.min(1)`, the alias and guidance refusals, and full-read-set and
showcase controls. A last control shows that `defineStack` does not
judge an identity-only line-items column, while the same column under
`object-master-detail-form` is judged.
- New showcase test
`examples/app-showcase/test/project-detail-line-items.test.ts`: the five
columns are keyed `name`, each names a `showcase_task` field, the block
parses against `RecordLineItemsProps` with its keys intact, and no
`field`-keyed line-items column exists anywhere in the showcase.
- **Prose made false by the change**: `validate-component-props.ts`
header (the skip list and its "earlier editions" history),
`validate-component-types.test.ts` comment, and the
`validate-page-field-bindings.test.ts` test title ("skips a component
type its descriptor table does not carry"). Those are comments and a
title only; no lint behaviour changed.
- **Generated**:
- `dropped-refinements.baseline.json`: new site
`ui/RecordLineItemsProps` at `columns.element` and `filter.element`,
plus its two counts.
- Also regenerated: `api-surface/ui.json`, `export-origins/ui.json`,
`declaration-map/ui.json`, `authorable-surface/ui.json`,
`json-schema.manifest/ui.json`,
`content/docs/references/ui/component.mdx` and `index.mdx`, and
`docs/audits/...strictness-ledger.counts/ui.md`.
- **Changeset** `.changeset/21142-line-items-columns-name.md`:
`@objectstack/spec` minor, BREAKING, `Clause-②: yes (narrowing)`,
ADR-0087 `registered ui-record-line-items-props-closed`. What reads it
is the component-props gate (advisory findings on `objectstack validate`
/ `build` / `lint`). The stored-page save and load path does not parse
`properties`.

## Measurements

**Premise**: holds. At `origin/main` `1ecb871beb`,
`project-detail.page.ts:76` authors `amountField: 'estimate_hours'` and
`:79`–`:104` author five `columns` keyed `field:`. `record:line_items`
was the only entry of `STRING_ARM_REGISTERED_TYPES`
(`component-type-vocabulary.ts:68`), and its row-lessness was pinned in
`validate-component-props.test.ts:495`.

**Read set at the `.objectui-sha` pin `31971ff1e28f`** (objectui
`packages/plugin-form/src/LineItemsPanel.tsx`; `SchemaRenderer` hoists
`properties` onto `schema`). A count of `schema.KEY` reads gives exactly
fifteen keys:

- `childObject` `:319`, `:327`, `:498`, `:516`, `:638`, `:673`, `:702`,
`:778`
- `relationshipField` `:515`, `:674`
- `columns` `:702`
- `parentObject` `:221`
- `parentId` and `recordId` `:228`
- `amountField` `:669`, `:703`
- `totalField` `:667`, `:669`, `:703`
- `title` `:722`
- `readonly` `:706`, `:707`, `:723`, `:810`
- `minRows` `:704`
- `maxRows` `:705`
- `filter` `:366`
- `sort` `:368`, `:377`
- `limit` `:341`, `:437`

The wrapper adds no key. `ElementDataSourceGate.tsx` reads the
node-level `dataSource` plus the same `filter` / `sort` / `limit`
(`:421`, `:434`, `:445`). The mapping `RECORD_LINE_ITEMS_DATA_SOURCE`
(`plugin-form/src/index.tsx:556`) writes the binding's `object` onto
`childObject`. `requiredPermissions` and `aria`, which other record rows
declare, have no read here, so they are not declared.

**objectui `main` (`d59f11c0d3dc`, pin is an ancestor: `merge-base
--is-ancestor` exit 0)**:

- Same fifteen keys; the per-key read counts are identical.
- One semantic difference. At the pin the grid's footer total appears
only when `totalField` is set (`total_field: schema.totalField ?
schema.amountField || 'amount' : undefined`). On `main` (objectui
`55a12a8e1`, round 8) it appears whenever `amountField` is named. So the
showcase's `amountField` with no `totalField` draws a footer only once
the console pin moves past that commit.
- `GridField` on `main` declares `GridColumn = InlineGridColumn`, which
is the spec's type by reference (objectui `75dcc81c3`).
- `0a3e5409f` (grid `sort_field`) changes `GridField` and
`MasterDetailForm`, not anything this block reads or hands the grid.
- No key is read at the pin but retired on `main`.

**Column shape (Zone 2 objectstack-ai#4)**: at the pin, `GridField.tsx`'s `GridColumn`
interface declares exactly the twenty keys `InlineGridColumnSchema`
declares (`name`, `label`, `type`, `options`, `width`, `required`,
`prefix`, `step`, `reference`, `displayField`, `idField`, `multiple`,
`accept`, `defaultHidden`, `computed`, `expr`, `scale`, `autofill`,
`readonlyWhen`, `requiredWhen`). Same shape, so the row references the
schema by identity. The one difference belongs to the carrier:
`LineItemsPanel` hands `columns` straight to `applyColumnPermissions`
and then the grid (`:702`), with no `hydrateColumns` step. An
identity-only `{ name }` column therefore draws as a text cell headed by
its name, and the `describe()` says so.

**Census (Zone 2 objectstack-ai#5)** at `1ecb871beb`, matcher `type:
'record:line_items'`:

- `examples/`: 1 producer, the showcase page, 5 `field`-keyed columns,
respelled here.
- `content/docs/`: 0 blocks. One prose tag-list mention in
`ui/react-pages.mdx:38`.
- `packages/` non-test: 0.
- Lit control: the same matcher shape finds 8 other `record:*` blocks in
`examples/` (3 `record:details`, 2 `record:highlights`, 1 each
`record:path`, `record:quick_actions`, `record:alert`).
- Test fixtures naming the type: the three lint test files above. Each
was re-judged: one flipped, one comment moved, and one title made
honest. The field-bindings fixture is not a props-gate input.

**Served showcase page end to end: NOT MEASURED.** Neither checkout has
a console build (`packages/console/dist` is absent in both). `pnpm dev`
runs `check:console-sha` first, and producing that build needs a full
objectui build at the pin, which this dispatch holds read-only. What is
measured instead:

- The page's block parses against the row with its five `name` keys
intact (showcase test).
- The objectstack conversion registry carries no `record:line_items`
rewrite (`git grep` in `packages/spec/src/conversions`: 0 hits), so no
load-time conversion on this side intervenes.

## Tests (at HEAD `c2b91013`)

All through `scripts/pm/os-verify-lock.sh`, exit codes read from its
`VERDICT` line. The tree is the merged one: `origin/main` `62b90d74`
merged through `os-regen-merge.sh`, plus the regeneration commit.

| command | result |
|:--|:--|
| `pnpm --filter @objectstack/spec exec vitest run --project local
--maxWorkers=2` | 596 files, 17466 passed, 1 todo |
| `pnpm --filter @objectstack/spec run typecheck` (tsc + scripts + test
layer) | exit 0 |
| `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` | 119
files, 5503 passed |
| `pnpm --filter @objectstack/lint run typecheck` | exit 0 |
| `pnpm --filter @objectstack/sdui-parser exec vitest run
--maxWorkers=2` | 13 files, 217 passed |
| `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2` | 200 files passed, 3 skipped; 2973 passed, 19 skipped |
| `pnpm --filter @objectstack/example-showcase exec vitest run
--maxWorkers=2` | 30 files, 391 passed |
| `pnpm --filter @objectstack/example-showcase run typecheck` | exit 0 |
| `pnpm --filter @objectstack/spec check:generated` | all 15 artifacts
up to date |

- Dependency closures were rebuilt first (`pnpm
--workspace-concurrency=2 --filter` with the `PKG^...` closure of lint,
metadata-protocol, sdui-parser and the showcase, plus `@objectstack/spec
build`).
- `protocol.meta-types-degenerate-derivation.test.ts` (the served-schema
count pins) is green unchanged. The new row moves no count: `page` still
serves 24 top-level properties, because `properties` is an open record.
- **Pre-merge** (`2479fb67` / `742c6970`): the same suites were green.
Six transient failures — module-not-found on `@objectstack/spec/*` and
`@objectstack/platform-objects/*` — came from a concurrent dist rebuild
by the gate run. They were re-run on stable dists: green (4 files / 30
tests, and 30 files / 391 tests).
- **eslint, narrowed (measured)**: `eslint --no-inline-config --format
json` over the 12 changed `.ts` files reports 12 results, 0 errors, 0
warnings, and none ignored, so all 12 are in the config's population.
`eslint.config.mjs` never enables type-aware linting (its `:327-329`),
so this diff cannot move the verdict on any file it did not touch. The
repo-wide `pnpm lint` is CI's.

## Reverse verification (ablation)

The map row `'record:line_items': RecordLineItemsProps,` was deleted
through `scripts/ablation-replace.mjs` (anchor 1 → 0, blob
`35459aca2181` → `d052475a42e4`), committed state first. Then `pnpm
--filter @objectstack/spec build`. `ablation-dist-preflight.mjs
--absent` reported the marker absent from all 230 built files, and the
tree carried only the source mutation.

- Spec (src): `component-type-vocabulary.test.ts` +
`inline-grid-column-carriers.test.ts` → **8 failed** / 55 passed. These
are the vocabulary pin and seven of the eight new carrier tests. The
eighth, the `defineStack` control, reads no row and stays green as
intended.
- Lint (spec `dist`): `validate-component-props.test.ts` → **1 failed**
/ 49 passed. "reports a `field`-keyed column" saw zero findings, which
is the pre-fix silence. Its `name`-keyed control stays green (vacuously
under the ablation).
- Showcase: 4/4 green, as expected, because that test parses with
`RecordLineItemsProps` directly rather than through the map.
- Restore: `git checkout HEAD`, blob back to `35459aca2181` == HEAD,
`git diff HEAD` empty, whole-tree `git status --porcelain` clean.
Rebuilt spec, preflight "marker present in 14 built files" and "working
tree clean against HEAD". Lint suite back to 50/50.

## Gates

- Derived with `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` (no paths) at `c2b91013`: **113
commands**, the same list as the pre-merge derivation.
- All 113 ran at `c2b91013`, sequentially, each exit code captured
before any pipe: **113 × exit 0**. This includes
`check:adr-0087-registration` (`registered
ui-record-line-items-props-closed (new here)`),
`check:changeset-no-major`, `check:nul-bytes`, `check:issue-citations`,
`check:doc-authoring`, `check:spec-parsed-alias`,
`check:migration-registry`, `check:dual-build-cjs-loads`, and
`check:type-check-debt` (re-measure, 336s).
- `dispatch-gates --ran` reconciliation: 113 derived, 113 run, 0
NOT-MEASURED, 0 UNRUN.
- The pre-merge run (`ffcd210a`), taken alongside a closure build, did
not measure seven gates. Five answered PREREQUISITE NOT MET (exit 3).
`check:dts-closure` named a package whose declarations were mid-rebuild.
`check:type-check-debt` hit a 420s cap. All seven are in the 113 × exit
0 above.
- **NOT MEASURED here (CI's)**: the CI jobs that dispatch-gates names as
outside the list (Test Core shards, Dogfood, Temporal Conformance, Build
Core, the four type-check lanes), and the repo-wide `pnpm lint`.

## Acceptance notes

- **`amountField` footer differs between the pin and objectui `main`**
(above). This is not a defect here: the console pin bump carries it in.
- **Two spellings of one concept, both read**: `parentId` wins over
`recordId` (`LineItemsPanel.tsx:228`). Both are declared as measured, on
the `object-master-detail-form` `initialValues` / `initialData`
precedent, and the `describe()` names the precedence. Retiring one is a
separate enforce-or-remove question; not filed (no reach measured).
- **The objectui mirror is objectui's**: objectui#10872 waits on this
row. Two items there are now stale: objectui
`packages/types/src/zod/public-blocks.zod.ts:167`, which says "the spec
carries no row", and the registry `inputs` for this block, which declare
5 of the 15 keys.
- **`field-no-consumers`** (`validate-field-consumers.ts`) walks child
collections by the keys `subforms` / `details`, so it does not credit a
`record:line_items` block's column names to the child object. Reach was
not measured: the showcase task fields are consumed elsewhere. Noted
only.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…export options object, and a bare format array is refused (objectstack-ai#21229) (objectstack-ai#21287)

Fixes objectstack-ai#21229
Clause-②: yes

Dispatched by the PM claim `5943166878` (PM loop round 1, `domain:spec`
seat 1), on the triage ruling `5939380297`.
`ComponentPropsMap['object-grid'].exportOptions` was `z.unknown()`. It
now takes `ListViewExportOptionsSchema`, the list view's strict
five-member export options object, **by identity**. It does not take the
list view's union, whose legacy bare-array arm lifts to `{ formats }`. A
bare array is refused with the object form named. The changeset carries
the `(narrowing)` arm, the BREAKING banner at `minor`, and the ADR-0087
marker `registered ui-object-grid-export-options-closed`. It is D3 only;
the reading is below.

## What changed

- **New non-barrel module
`packages/spec/src/ui/list-view-export-options.ts`.** It holds the
export options block, moved verbatim out of `view.zod.ts`: the
retired-`'pdf'` prescription, the `csv` / `xlsx` / `json` format enum,
and the strict five-member object.
- There is one addition. The object's own error map answers an
`invalid_type` on an array input with a prescription naming `{ formats:
['csv', 'xlsx'] }`.
- It is the object's map because that is the only map a type failure at
this position consults. A wrapper's or the enclosing row's map is never
reached, and an object-level refinement never runs once a property has
failed its type.
- The object is built exactly as `strictObject()` builds one:
`closedObject(z.object(shape, { error }).strict())` with the same
registered `strictObjectError` declaration. The `prime` handle is
forwarded, so `closedObject`'s terminal unknown-key contract is kept.
- **New non-barrel module `packages/spec/src/ui/view-history.ts`.**
`VIEW_HISTORY` moved here, verbatim, so the moved block keeps the
refusal sentence it has always carried. `view.zod.ts` imports both
modules, and its 53 `VIEW_HISTORY` uses are unchanged.
- **`component.zod.ts`:** `exportOptions:
ListViewExportOptionsSchema.optional()`. The "Unvalidated here" describe
text is gone. A docblock records the ruling and the door reading.
- **D3 entry** `18.ui-object-grid-export-options-closed.ts`, regenerated
into `migrations/registry.ts` by `gen:migration-registry`.
- **`STEP18_RATIONALE` fragment**
`ui-object-grid-export-options-closed`, `order: 59`. `main` holds 57 (PR
objectstack-ai#21244) and 58 (PR objectstack-ai#21240) at the merge `b91e40bc89`. It is inserted at
its sorted position.
- **Pin file `component-object-grid-export-options-members.pin.test.ts`,
rewritten.** The objectstack-ai#17166 version asserted the key was still unvalidated,
so that this change would red there and be decided deliberately; it did.
The new file holds the triage pins:
- **identity:** the row's inner schema is the very instance the list
view's union holds as its object arm, read from the union rather than
imported by name, and it is not the union;
- **bare array refused:** `invalid_type` at `exportOptions`, with the
object form named. The control is the same array on a list view, which
still lifts to `{ formats: ['csv'] }`;
  - **object form accepted:** all five members, `{}` and absent;
- **a format outside the enum refused:** `invalid_value` at
`exportOptions.formats.1`, and `pdf` with its retirement text;
- **an undeclared key refused:** `unrecognized_keys` at `exportOptions`,
naming `this export options block` and the `maxRecord` → `maxRecords`
rename.
- **Regenerated:** `content/docs/references/ui/component.mdx` (the row's
type, and a new nested-shape table) and
`docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md` (see
the strictness-ledger note under Acceptance notes).

## Measured: why a non-public module, not an export (Zone 2 item 1)

- `ui/index.ts` is `export * from './view.zod'`, so any export from
`view.zod.ts` is public.
- **Measured.** I added `export { ListViewExportOptionsSchema }` to
`view.zod.ts` through `scripts/ablation-replace.mjs` in wrap mode, then
rebuilt spec. The first attempt was refused by the tool as a no-op,
because the anchor was inside the replacement; it was re-anchored and
re-run. Results:
- `check:api-surface` turned red with `./ui +
ListViewExportOptionsSchema (const)` ("0 breaking, 1 added"), and
`check:export-origins` turned red;
- the build also wrote `ui/ListViewExportOptions` into
`json-schema.manifest/ui.json` (a new published JSON Schema def) and 5
`ui/ListViewExportOptions:*` rows into `authorable-surface/ui.json`.
- The file was restored, blob equal to HEAD and `git diff HEAD` empty.
The two dirtied artifacts were restored with `git checkout HEAD`, and
after a clean rebuild `check:api-surface` reads "unchanged ✓".
- **objectui.** At the pin `31971ff1e28f`,
`packages/types/src/__tests__/export-options-spec-parity.test.ts`
asserts `expect(specUi.ListViewExportOptionsSchema).toBeUndefined()`. An
export would red that test at objectui's next spec bump.
- **With the non-barrel module**, `check:api-surface`,
`check:export-origins`, `check:declaration-map` and
`check:authorable-surface` are unchanged. One declaration; zero public
surface. This is the `analytics-column-reference.ts` /
`analytics-carrier-filter.ts` / `section-group-reference.ts` precedent.

## Measured: the ADR-0087 disposition is D3 only (Zone 2 item 2)

Every pre-PR shape that the pinned renderer handles and the PR refuses
was put through every door, before (`f148852752`) and after
(`8b2c2bb558`, the schema commit, same `src/ui` as HEAD). The reading
uses the built `dist`, `getMetadataTypeSchema('page')` (the save door's
per-type parse), `defineStack`, the props gate `validateComponentProps`,
and `runtimeAuthoringRulesFor('page')`.

| shape (on an `object-grid` node's `properties`) | at the pinned
renderer (`ObjectGrid.tsx` at `31971ff1e28f`) | row before → after |
save door | `defineStack` | props gate after |
|:--|:--|:--|:--|:--|:--|
| `['csv']` | `!!exportOptions` is true, so the menu shows the csv/json
default; the list is dropped | accept → `invalid_type` | ok | accepted |
warning `component-props-invalid` |
| `{ formats: ['csv'], foo: 1 }` | renders; `foo` is never read | accept
→ `unrecognized_keys` | ok | accepted | warning
`component-props-unknown-key` |
| `{ formats: ['pdf'] }` / `['xml']` | the value is hidden from the menu
with a `console.warn` | accept → `invalid_value` | ok | accepted |
warning `component-props-invalid` |
| `null` | `!!null` is false, so no menu, the same as absent | accept →
`invalid_type` | ok | accepted | warning `component-props-invalid` |
| `true`, `'csv'`, `{ formats: 'csv' }`, `{ maxRecords: -1 }`, `{
streaming: 'false' }` | renders, with the default or a misread | accept
→ refused | ok | accepted | warning `component-props-invalid` |

- **Lit controls, same run.** An undeclared `object-grid` prop gives the
props-gate warning `component-props-unknown-key`. An undeclared page key
is REFUSED at the save door and THROWS in `defineStack`. The accepted
object forms (`{ formats: ['csv','xlsx'] }`, `{}`, all five, absent)
give no finding anywhere.
- **The runtime publish gate for `page`** runs one rule,
`validatePresetComparands`. The props gate is `tier: 'advisory'`,
`surfaces: CLI_ONLY`.
- **The renderer.** At the pin, objectui runs its zod mirror only in the
`objectui validate`/`check` CLI. `SchemaRenderer` runs a structural
`validateSchema` in dev only.
- **So nothing on the save or load path refuses any of these shapes.** A
stored page saves and loads, and no conversion has a load-path refusal
to pre-empt.
- **Lossless rewrites.** The undeclared key, `pdf` and `null` have one
(delete it), but nothing stops loading. The bare array has none that
both keeps today's menu (`{}`) and honours the author's list (`{ formats
}`), and that choice is the upgrader's, which the D3 entry states.
Triage also ruled out a lift.

## Census (Zone 2 item 3), on `f148852752`

- **Zero `object-grid` blocks author `exportOptions`** in `examples/**`,
the package fixtures, `content/docs/**` and `skills/**`.
- **Control:** the same census finds 10 authored `object-grid` blocks.
Nine are TypeScript nodes (two showcase pages and seven package-test
fixtures). One is the YAML example in
`content/docs/protocol/objectui/layout-dsl.mdx`. The `exportOptions`
matcher is lit on the 4 list-view authorings: `app-crm` ×2, the showcase
task view, and the lint showcase fixture.
- **`skills/**`:** nothing teaches `exportOptions`.
`skills/objectstack-ui/rules/pages.md` names `object-grid` in prose
only, so no Tier H follow-up is owed.
- Nothing needed respelling.

## objectui (Zone 2 item 4, Post-Task Checklist objectstack-ai#4)

Nothing the pinned objectui imports moves:

- `check:api-surface` is unchanged, and no export was removed or
renamed.
- `ListViewSchema.shape.exportOptions` is still a two-arm union with one
five-key object arm, which is what objectui's
`SPEC_EXPORT_OPTIONS_OBJECT_SHAPE` peel reads.
- `ListViewExportOptionsSchema` is still not exported, so objectui's
floor test holds.
- No pinned objectui test parses the row with `exportOptions` and
expects a bare array to pass. Six pinned tests mention `exportOptions`
near the row; two of them carry only prose that will go stale (see
Acceptance notes).

## Changes outside the row, stated

- **The list view's nested message.** The list view's `exportOptions`
accepts and lifts exactly what it did, and its top-level messages are
unchanged. Only when a bare array also fails the array arm (`['docx']`)
does the object arm's nested branch message read the new prescription
instead of `Invalid input: expected object, received array`. Measured on
`dist`. Both carriers read one declaration, and the text is worded to be
true on both. The changeset says so.

## Tests and gates: all on `032865c93c` (HEAD, the merge of
`origin/main` `b91e40bc89` through `os-regen-merge.sh`)

- `pnpm --filter @objectstack/spec exec vitest run --project local
--maxWorkers=2`: **597 files passed, 17475 passed, 1 todo**.
- `pnpm --filter @objectstack/spec typecheck`: exit 0.
`check:test-typecheck` is OK (52 files / 246 errors / 135 pinned
signatures, unchanged), so the rewritten pin compiles under
`tsconfig.test.json`.
- **Contract-face fixture triage.** These are consumers of the spec, the
downstream (`...@objectstack/spec`) direction, limited to the three the
dispatch names:
- `@objectstack/lint` (the props gate): **119 files / 5515 tests
passed**;
- `@objectstack/metadata-protocol`: **200 passed + 3 skipped files /
2973 passed + 19 skipped tests**;
  - `@objectstack/spec`: as above.
  - No fixture in any of them needed a change.
- `pnpm --filter @objectstack/spec check:generated`: 15 of 15 up to date
after `--fix` regenerated the 2 it proved stale (`check:docs`,
`check:strictness-ledger`).
- **`dispatch-gates.mjs --commands`** (no paths) derived 112 commands;
all 112 ran and exited 0.
- Six first exited 3 with PREREQUISITE NOT MET, because `lint`,
`client-react` and `objectql` had no `dist`:
`check:doc-formula-expressions`, `check:doc-security-posture`,
`check:skill-examples`, `check:docs-transcript-drift`,
`check:dual-build-cjs-loads` and `check:lean-entry-closure`. Each re-ran
green once the dists existed.
- `--ran` with `cmd :: exit N`: "112 derived, 112 run, 0 NOT-MEASURED, 0
UNRUN (a DERIVED zero)".
- **Reverse verification, cross-package type.** A scratch `probe.ts`
typed against the rebuilt `dist/ui/index.d.mts`, using
`ObjectGridProps`, was compiled with `tsc`:
  - `exportOptions: ['csv']` gives `TS2559`;
  - `{ formats: ['xml'] }` gives `TS2322`;
  - `{ formats: ['csv'], maxRecord: 1 }` gives `TS2561`;
  - the control `{ formats: ['csv','xlsx'], maxRecords: 10 }` compiles.
- At the base the key was `unknown` (the reference page rendered `any`).
- **NOT MEASURED, declared to CI:** the 6 path-scheduled CI jobs (Test
Core shards, Temporal Conformance, Dogfood Regression and Verify, Build
Core, Build Docs), the 4 workspace type-check lanes, and the 54
artifact-roster families the derivation lists outside its total.

## Acceptance notes

- **The strictness ledger's scope.** The ledger
(`check:strictness-ledger`) counts `.zod.ts` files only. The moved block
is one CLOSED site and now lives in a non-`.zod.ts` module, like the
other non-barrel helpers, so the regenerated `ui/` counts read 189 → 188
sites and 179 → 178 strict. The strip count, which is the ratchet's
target, is unchanged at 7. Naming the module `.zod.ts` would have kept
the site counted, at the price of a hand-written ledger row and of
opting a non-public module into the `.zod.ts` generators' discovery.
- **Stale prose in objectui.** At the pin, two objectui test files say
the spec row is `z.unknown()`: the
`ObjectGrid.exportOptionsKeys.test.ts` docblock, and the
`object-grid.exportOptions` row in
`registry-inputs-spec-parity.test.ts`'s `MEMBER_PINS`. Neither asserts
it, so nothing goes red. Once the spec version carrying this lands, both
describe a past state. Carrier: objectui's next spec pin-bump PR. Noted,
not filed.
- **objectui's `properties` bag.** On objectui `origin/main`, the bag
arm from objectui#11399 judges the bag by
`ComponentPropsMap['object-grid']` by reference. Once objectui installs
this spec, its `objectui validate` refuses a bare array in the bag, as
its flat mirror has since objectui#7762. No objectui change is owed
beyond the pin bump.
- **A public export, if objectui later wants one.** objectui's
export-options parity test says "When upstream exports the symbol,
derive from it and delete both the mirror". Publishing
`ListViewExportOptionsSchema` stays possible as its own decision. It
moves `api-surface` (+1 const), `json-schema.manifest` (+1 def) and
`authorable-surface` (+5 rows), as measured above. It is not done here.

---

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants