Skip to content

fix(cli): the strict config refusal now carries the rule it enforces — a named export of objectstack.config.ts IS a top-level stack key - #18416

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-18171-config-module-single-export
Sep 16, 2026
Merged

os-support-ai merged 3 commits into
mainfrom
claude/issue-18171-config-module-single-export

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes #18171

Clause-②: no — this PR puts no new key on a published payload. It keeps the strict config parse exactly as it is and only documents the existing constraint and improves the diagnostic; no input the build refuses today becomes acceptable. Declared by the dispatching seat in claim 5694749953.

Branch B as ruled in claim 5694749953: the strict parse stays, the constraint becomes discoverable. Branch A (read only the default export) is not taken, and nothing here drifts toward it — the acceptance set is byte-for-byte what it was.

The landing point, corrected

The dispatch pointed at packages/cli/src/utils/config.ts "docblock around line 261". That docblock belongs to authoredSourcePlugin (the os migrate meta shim) and is not where the module's exports are collected. The real merge is in loadConfig() itself:

const merged: any = { ...baseConfig };
for (const key of Object.keys(mod)) {
  if (key === 'default' || key in merged) continue;
  merged[key] = (mod as any)[key];
}

The config file is therefore loaded as a module: the default export is the base, and every named export is merged onto it as a top-level stack key, under its own name. That merge is deliberate and load-bearing — onEnable and functions are declared stack keys an app authors as named exports, and unwrapping mod.default alone dropped them.

The card's finding reproduces here; its wording does not

Measured on this tree (not on the hotcrm pin the card read), through the real loadConfig() and the real ObjectStackDefinitionSchema:

the named export merged config strict parse
ProbeNamedExport — not a declared key ["manifest","ProbeNamedExport"] fails, unrecognized_keys, key named
objects, default carries no objects ["manifest","objects"] passes — the export is honoured
objects, default already has objects ["manifest","objects"], value [] passes — the export is dropped silently

So the diagnostic gap is real and the card is right to file it, but "the config file may carry no named export" / "exactly one export, the default stack" is too strict to document as the rule: rows 2 and 3 do not fail. What the code enforces is

a named export is legal only when its name is a key ObjectStackDefinitionSchema declares

and that is what this PR writes down. Documenting the card's sentence verbatim would have described a constraint the build does not have, and would have outlawed onEnable and functions.

Row 3 is a second, separate defect — a metadata-authoring trap, not a documentation gap — and it is out of scope here: closing it changes what the build accepts, which is branch-A territory and owes a pm:retriage. It is reported to the dispatching seat to file. See Acceptance notes.

Before / after

Both captured end-to-end through os validate on the same project, on this branch. The "before" leg is the same binary with namedExportRejectionHints ablated to return nothing (restore verified by blob hash).

Before — the refusal names the key, and nothing tells the author why a key they never wrote is being judged:

  ✗ Validation failed
  _root:
    ✗
      unrecognized_keys: Unrecognized key(s) on this stack definition: `ProbeNamedExport`. Until this surface was closed …
  1 validation error(s) total

After — same refusal, same exit code (1), same --json payload, with the rule and the fix added on the text face only:

  ✗ Validation failed
  _root:
    ✗
      unrecognized_keys: Unrecognized key(s) on this stack definition: `ProbeNamedExport`. Until this surface was closed …
  1 validation error(s) total
  `ProbeNamedExport` is a NAMED EXPORT of your config file, it is not a key written inside defineStack().
  The config file is loaded as a MODULE: every named export is merged onto the default-exported
  stack as a top-level key, so a named export is legal only when its name is a key the stack
  schema declares. A helper exported beside the stack is read as a stack key, and refused above.
  Fix: move it into a sibling module (e.g. objectstack.composition.ts) and import it here.

What changed

  • packages/cli/src/utils/config.ts — loadConfig()'s header now states the rule where the rule is created; LoadedConfig gained a namedExports reading (the provenance the explanation needs, and nothing else); new namedExportRejectionHints() turns a root unrecognized_keys issue into the rule and the fix, for exactly the keys the merge put there.
  • packages/cli/src/commands/compile.ts and validate.ts — both authoring doors print it, so os build and os validate do not disagree about what an author is told. ⛔ Text face only: the --json branches are untouched, so no field is added to a published envelope.
  • packages/cli/src/commands/init.ts — all three os init templates scaffold the constraint as a comment at the head of the config they write. No ADR id, no issue number, no repo-relative path — init-template-comments-self-contained sweeps that population and is green.
  • content/docs/getting-started/your-first-project.mdx and content/docs/deployment/cli.mdx — the rule on the config-authoring surface and in the CLI configuration reference, with all three rows of the table above named.
  • .changeset/18171-config-module-named-export-rule.md — @objectstack/cli patch.

The pin, and that it can fail

packages/cli/src/utils/config-named-export-rule.test.ts, 5 cases. The refusal comes first, because it is the property the ruling protects: the helper export still reaches the strict parse and is still refused by name, with the unrecognized_keys code and the key echoed back. Then the hint's rule and fix; then the two things it must NOT claim (a key the author really did write inside defineStack(), and a nested unrecognised key); then the two-door parity.

Reverse verification, from the committed fix, mutation proved on disk, restored with git checkout HEAD -- THE_PATH:

mutation   `if (offenders.length === 0)` -> `if (offenders.length >= 0)`  (the hint returns nothing)
on disk    grep -c 'offenders.length === 0'  1 -> 0        blob d0043a8f -> a6911cd6
result     Tests  1 failed | 4 passed (5)   — "the hint carries the RULE and the FIX"
restore    git checkout HEAD -- …; hash back to d0043a8f; `git diff HEAD` empty
result     Tests  5 passed (5)

Direction is worth stating plainly: the two negative cases assert [] and therefore stay green under this mutation. Only the positive case is load-bearing against it — which is exactly why the file does not consist of negative assertions.

Verification

Tree measured: f6561b2b0 (working tree identical to that commit throughout; git status clean).

run exit
93 gates derived by scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack 92 × 0
pnpm check:cross-package-test-inputs 1 — pre-existing, not this PR
pnpm lint (whole repo, re-run at f6561b2b0) 0
pnpm --filter @objectstack/cli typecheck 0
pnpm --filter @objectstack/cli exec vitest run --project unit 0 — 209 files, 2980 tests
pnpm build (whole repo) 0

check:cross-package-test-inputs names packages/cli/test/init-created-files-summary.e2e.test.ts descending packages/spec/dist/ — a file this diff does not touch. Filed as #18353 / #18348; all three sibling cards this round hit it independently.

packages/cli's integration tier is declared to CI: this diff touches no integration-tier file, no bin/ entry and no driver/kernel boot path, so only the unit project was owed locally.

Two gates were red because of this PR and are now green: check-reference-carrier-shape (exit 3) and check:comment-mask-corpus (exit 1) both refused to parse init.ts, because the first draft of the scaffold comment used backticks inside the template literal that renders it and closed the literal. Fixed in f6561b2b0.

Acceptance notes

Filed via the report on #18171 (class c — a metadata-authoring trap; dev does not open cards):

  • A named export whose name the default export already carries is dropped silently and the build exits 0. if (key in merged) continue in loadConfig(). Measured above: export const objects = [{…}] beside defineStack({ manifest, objects: [] }) leaves config.objects === [], the authored row never reaches the artifact, and nothing is printed at any level. Dedupe words: config named export shadowed, loadConfig key in merged, silently dropped stack key, objectstack.config module merge.

Noted, not filed:

  • os create (packages/cli/src/commands/create.ts) and create-objectstack also scaffold an objectstack.config.ts and did not get the comment. The dispatch's declared file surface names the os init template only, so widening it is the dispatching seat's call rather than this PR's. Carrier: whoever takes the follow-up — it lands in the same init-template-comments-self-contained population this PR already passes, so it needs no new verification surface.

Generated by Claude Code

`objectstack.config.ts` is loaded as a MODULE: `loadConfig()` takes the
default export as the base and merges every NAMED export onto it as a
top-level stack key, under the export's own name. That merge is deliberate
(`onEnable` and `functions` are declared stack keys authored as named
exports), but its consequence was written down nowhere: a named export is
legal only when its name is a key `ObjectStackDefinitionSchema` declares, so
a helper exported beside the stack reaches the strict parse as a top-level
stack key of that name and is refused as unrecognised.

The refusal is unchanged — same key, same `unrecognized_keys`, same failing
parse, same exit code, same `--json` payload. `os build` and `os validate`
now print, on the text face only, the rule behind it and the fix. The rule is
also stated on the config-authoring docs page, in the CLI configuration
reference, and in the comment every `os init` template scaffolds.

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

The `os init` templates render their `objectstack.config.ts` from a template
literal, so the backticks around `onEnable` / `functions` in the new comment
terminated the literal and left `init.ts` unparseable — 71 parse diagnostics,
and two gates that read source through a parser refused to score the file at
all (`check-reference-carrier-shape` exit 3, `check:comment-mask-corpus`
UNPARSEABLE). The comment says the same thing without the quoting.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 7 documentable anchor(s).

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 588475c30f98ffb5ee603382d585e41cd9a59753.

⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 49 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 588475c30f98ffb5ee603382d585e41cd9a59753 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 58f6c222f62f21cb6aed5aed19372a0e808d5c54 — the merge of head f6561b2b0e183878301311993e08de400b05a656 into base 588475c30f98ffb5ee603382d585e41cd9a59753, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 58f6c222f62f21cb6aed5aed19372a0e808d5c54 && git checkout 58f6c222f62f21cb6aed5aed19372a0e808d5c54
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 588475c30f98ffb5ee603382d585e41cd9a59753 f6561b2b0e183878301311993e08de400b05a656 && git checkout -B drift-repro 588475c30f98ffb5ee603382d585e41cd9a59753 && git merge --no-ff f6561b2b0e183878301311993e08de400b05a656

node scripts/docs-audit/affected-docs.mjs --json 588475c30f98ffb5ee603382d585e41cd9a59753

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 588475c30f98ffb5ee603382d585e41cd9a59753 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 16, 2026
@os-support-ai
os-support-ai marked this pull request as ready for review September 16, 2026 10:24
@os-support-ai
os-support-ai added this pull request to the merge queue Sep 16, 2026
Merged via the queue into main with commit ecf3e3b Sep 16, 2026
38 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-18171-config-module-single-export branch September 16, 2026 10:48
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…hat it attempted (objectstack-ai#18445)

Fixes objectstack-ai#18432

Clause-②: no — this PR puts no new key on a published payload. It
changes only a progress line's text and the doc transcript that quotes
it; no input the build accepts or refuses changes. Declared by the
dispatching seat in claim `5696261079`.

## What changed

`os build` / `os compile` printed its package-docs step line **before**
the call it announces, and the line carried no count:

```
if (!flags.json) printStep('Collecting package docs (ADR-0046)...');
const docsResult = collectAndLintDocs(...)
```

So a build that collected nothing emitted the same reassuring sentence
as one that collected four documents. The print now happens after the
collection and reports it:

```
  → Collecting package docs (ADR-0046)... 0 collected
  → Collecting package docs (ADR-0046)... 4 collected
```

This is the reassurance half of objectstack-ai#18170. objectstack-ai#18428 landed the audible half,
where an uncollected docs directory speaks for itself; this card was
fenced out of that PR because the transcript quoting the step line lived
on a page PR objectstack-ai#18416 had staked. That page merged at 2026-09-16T10:48Z,
so the fence is lifted.

## The doc half is a measurement, not a copy-edit

`content/docs/deployment/cli.mdx` carries the step line inside a fence
tagged `transcript=os-build`. The number written there is the sample
fixture's real count, **measured, not chosen**:

- **N = 0**, read off a real `os build` run.
- **Where measured**: the fixture the page's own callout names — the
`blank` starter from `packages/create-objectstack/src/templates/blank`
(the two-field `my_app_note` object and the three connector plugins)
plus the four-field ticket object, the view and the action from
`content/docs/getting-started/build-with-claude-code.mdx` §3, renamed
out of that page's `support_desk_` namespace into `my_app_`, with the
`support` app deliberately not added. Built out-of-tree in the session
scratchpad against this branch's `packages/`, run through
`packages/cli/bin/run-dev.js`.
- **The reconstruction is corroborated by the page's other numbers**,
which reproduce exactly on the same run and were not touched: `Running
author-time rules (45)`, `Data: 2 Objects 6 Fields`, `UI: 1 Views 1
Actions`, `Runtime: 3 plugins`.
- **Why 0**: neither the `blank` template nor the walkthrough ships a
`src/docs/` directory, and the stack declares no inline `docs:`. The
measured run is the transcript's own case of the sentence the card
drafted.

Measured run, verbatim:

```
  → Checking capability providers (objectstack-ai#3366)...
  → Collecting package docs (ADR-0046)... 0 collected
  → Writing artifact...

  ✓ Build complete (118ms)
```

## The transcript-drift gate does NOT hold this number — and that is
correct

Dispatch Zone 2 assumed `check:docs-transcript-drift` would hold the new
value. **Measured and falsified**, so it is reported rather than relied
on: that gate's `TOKENS` array in
`scripts/docs-audit/check-docs-transcript-drift.mjs` carries exactly one
row, `author-time-rule-count`, matching `author-time rules \((\d+)\)`.
It compares the `45` in this block and nothing else.

That is not a hole this PR opens. The gate is scoped by design to values
a **live registry** derives; `0 collected` is *fixture identity*, in the
same class as `2 Objects 6 Fields` and `1 Views 1 Actions`, which the
page's existing callout already covers ("everything else is fixture
identity and reproduces"). A `TOKENS` row here would have to build a
sample project rather than import a registry, which is a different gate.
Named so the next author does not read the drift gate's green as
vouching for this digit.

The number that gate *does* hold is unchanged by this PR, and
`check:docs-transcript-drift` was run.

## Tests

`packages/cli/test/build-docs-step-count.e2e.test.ts` pins the behaviour
**as a pair**, because a test that only asserted "the step line was
printed" passes on the defective tree — the defective tree printed it
unconditionally:

| fixture | step line |
|---|---|
| no `src/docs/` at all | `0 collected` |
| `src/docs/` present and empty | `0 collected` |
| two docs | `2 collected` |

Each run's printed count is also compared against the emitted
`dist/objectstack.json`, so a number that drifted away from the set it
describes cannot pass as text. The pre-fix spelling — the sentence with
nothing after the ellipsis — is pinned **absent**. `--json` is asserted
to stay one JSON document carrying no step text.

The suite spawns the CLI, so it reuses `childEnv()` and the
`bin/run-dev.js` + tsx shape of `build-json-advisory-parity.e2e.test.ts`
rather than inventing one.

⚠️ **Where this pin runs, stated so nobody reads "tests added" as "gated
on every PR".** The `.e2e.test.ts` name puts it in this package's
**nightly tier**: `packages/cli/vitest.config.ts` routes the population
through `OS_TEST_TIERS`, so the queue and per-PR runs collect the 212
non-tier files and this file is not among them; the nightly on `main`
collects the tier files, where it lands in `integration` by
`vitest-tiers.ts`'s SPAWN predicate. That is the package's own measured
cost design — every one of its 60 spawners lives there — so a new
spawner follows it rather than having me make a unilateral call about
the Test Core critical path. Locally it was run under that switch, and
`pnpm check:tier-file-adoption` is green.

## Declared deviation from the claim's file surface

Claim `5696261079` declares a two-file surface. This PR carries four
files, and the two extra ones are declared rather than quiet:

- `packages/cli/test/build-docs-step-count.e2e.test.ts` — the pin the
dispatching seat itself asked for in its suggested route ("the assertion
that would have caught the original defect"), and what AGENTS.md's
Post-Task Checklist step 1 owes.
- `.changeset/18432-docs-step-line-reports-count.md` —
`@objectstack/cli` is a published package and this moves user-visible
build output, so a `patch` changeset is owed and `skip-changeset` would
be wrong.

No production file outside the two the claim names is touched.
`content/docs/releases/` and `packages/spec/` are untouched.

## Verification

Gate derivation is from the **actual** changed files, recomputed by the
tool from the merge base rather than a hand-written list, and reconciled
back with `--ran` carrying each command's own exit code (captured before
any pipe):

```
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
  -> 93 command(s), change set: the 4 files above, merge base 55fd5ee

node scripts/pm/dispatch-gates.mjs --ran RAN_RECORD_FILE --repo objectstack-ai/objectstack
  -> 93 derived famil(ies) accounted for — 93 run, 0 NOT-MEASURED, 0 UNRUN
     (a DERIVED zero — all 93 recorded an exit code and none of them is 3)
```

**92 of 93 green.** The one non-zero is the known pre-existing red:

| gate | exit | reading |
|---|---|---|
| `pnpm check:cross-package-test-inputs` | 1 | **Pre-existing, not this
PR.** Its only finding is rooted in
`packages/cli/test/init-created-files-summary.e2e.test.ts` descending
`packages/spec/dist/` — a file this diff never touches, and the new test
file appears nowhere in the report. Filed as objectstack-ai#18353 / objectstack-ai#18348. |

Five gates first answered `PREREQUISITE NOT MET` against a partly-built
tree (`check:skill-examples` exit 1 on a missing
`packages/client-react/dist`, `check:dual-build-cjs-loads` /
`check:i18n` / `check:i18n-coverage` / `check:i18n-walk-parity` exit 3).
Those are *nothing was measured*, not passes and not findings, so they
were re-run after `pnpm build` and all five are **exit 0**. `pnpm
check:docs-transcript-drift`, `pnpm check:doc-anchors`, `pnpm
check:doc-authoring`, `pnpm check:docs-single-h1` and `pnpm
check:nul-bytes` are green on the edited page.

`pnpm lint` — the repo-wide `eslint . --no-inline-config`, which the
derivation never names — was run **whole**, not narrowed: **exit 0**, no
findings. No `.cache/objectui-*` existed in this worktree to pollute it.

**Tests**

```
pnpm --filter @objectstack/cli typecheck                     exit 0
  (check:test-typecheck: the test layer compiles under tsconfig.test.json;
   debt unchanged at 3 file(s) / 28 error(s) / 6 pinned signature(s))

pnpm --filter @objectstack/cli exec vitest run --project unit --shard=N/4
  shard 1  53 files / 883 tests passed
  shard 2  53 files / 750 tests passed
  shard 3  52 files / 622 tests passed
  shard 4  52 files / 737 tests passed          210 files / 2992 tests, all green

OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run \
  --project integration test/build-docs-step-count.e2e.test.ts
  Test Files  1 passed (1)        Tests  5 passed (5)
```

**Reverse verification — the pin can fail, proved rather than
asserted.** The fix was committed first, then the pre-fix ordering was
restored on disk and the suite re-run:

```
HEAD blob:     a1c7daf
ON-DISK PROOF: fixed-spelling count 1 -> 0; pre-fix-spelling count now 1
mutated hash:  a2b25a52a3bd89486f9266f0bd5e9de03de65d2c
result:        Tests  4 failed | 1 passed (5)
restored:      hash matches HEAD blob and `git diff HEAD` is empty
```

⚠️ **The predicted direction, stated before the run and then observed:
four red, one green.** The `--json` case is a *control*, not a
regression detector — `--json` prints no step line on either tree, so a
run in which it also went red would mean the ablation had hit something
other than the ordering. The four behavioural pins are the ones that
must move, and they did. The mutation was proved to reach disk by
occurrence count on the text being replaced, not by the editor's exit
code; the restore is proved by blob-hash equality plus an empty `git
diff HEAD`, not by a return code, and it runs from an `EXIT INT TERM`
trap with an absolute path.

**Where the measurement of N happened, reproducibly**

```
$ cd FIXTURE_DIR        # blank starter + ticket object/view/action, my_app_ namespace
$ tsx packages/cli/bin/run-dev.js build
  → Running author-time rules (45)...
  → Checking capability providers (objectstack-ai#3366)...
  → Collecting package docs (ADR-0046)... 0 collected
  → Writing artifact...
  ✓ Build complete (118ms)
  Data: 2 Objects  6 Fields
  UI: 1 Views  1 Actions
  Runtime: 3 plugins
```

The fixture was built in the session scratchpad, outside the repository,
and deleted; nothing of it is in this diff.

## Acceptance notes

- **Pre-existing red, not this PR's**: `check:cross-package-test-inputs`
exits 1 on a tree where `packages/spec/dist` is built. Already filed as
objectstack-ai#18353 / objectstack-ai#18348; no seventh card.
- **Noted, not filed** — the `transcript=os-build` block elides two
summary rows the current CLI prints for that fixture (`Logic: 0 Flows`
and `Security: 0 Positions 0 Permissions`) and the two
`field-no-consumers` advisories it raises. Elision is explicitly within
what the drift gate's design allows for a hand-authored transcript ("an
elided, annotated, sometimes abbreviated paste"), so this is an
observation about the page's editorial choices, not a defect, and it is
out of this card's declared scope. Carrier: the next PR that re-measures
this block, or a `docs-accuracy-audit` pass over
`content/docs/deployment/cli.mdx`.

Authored by Claude Code in session `session_01DvvamiacK328idtBYJBxV3`.


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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…bjectstack-ai#18170) (objectstack-ai#18428)

Fixes objectstack-ai#18170

Clause-②: yes

**Declared with the claim comment, unchanged.** The card offers two
repairs whose clause-② answers differ, so the claim took the
conservative arm before the direction was measured. The direction
delivered here is the **tightening** (see below); correcting the grade
is a tier-qualified seat's act, not this PR's — the changeset is graded
`minor` so the `yes` floor holds either way.

## What was wrong

`collectDocsFromSrc` reads exactly one directory —
`path.join(path.dirname(configPath), 'src', 'docs')` — and no other.
Under an ADR-0130 multi-package layout, where every top-level directory
under `src/` is a package, a docs directory belongs to its package:
`src/PACKAGE/docs/`. Move one there and the two conventions disagree in
the worst possible way:

- the collector reads nothing;
- `compile.ts` prints `Collecting package docs (ADR-0046)...`
unconditionally, before it knows whether anything was collected;
- the build exits **0** and writes an artifact with no `docs[]` at all.

Measured on `objectstack-ai/hotcrm` at `590b095` (pin 17.4.0) by the
filing seat: `git mv src/docs src/sales/docs` as the only change, four
package docs gone, nothing in the output naming the loss. Re-verified on
`origin/main` in this repo before writing a line: the fixed path is
`packages/cli/src/utils/collect-docs.ts:222`, the unconditional step
line is `packages/cli/src/commands/compile.ts:641`, and
`collect-docs.test.ts:80` builds its whole fixture on the same
`src/docs` join.

## The direction taken, and the measurement behind it

The card offers **either** of two repairs and does not pick. They are
not equivalent in kind:

| repair | kind | delivered |
|:--|:--|:--|
| read package docs from each package directory | **widens** the
accepted set — the build starts accepting a layout it refuses today | no
|
| fail or warn loudly when an expected docs directory is absent |
**tightens** — an existing silent loss becomes audible | **yes** |

The tightening is the card's own stated minimum and the higher-value
half under the charter's anti-error axis (「契约收紧…优于消费端宽容…宽容恰是 AI
批量犯错被掩盖的温床」). Two measurements decided it rather than preference:

1. **The widening is not fully specified by anything on this tree.** An
ADR-0130 D4 artifact registers **per package** (`packages[]`, option B),
and `compile.ts` attaches collected docs to the artifact's **top level**
(`finalBundle.docs`). Per-package docs therefore have to say *which
package body* they belong to, and that attachment point is open —
`lintDocs` also enforces a namespace prefix read from one
`stack.manifest.namespace`, which a multi-package stack does not have a
single value for. Choosing one is a contract decision, ⛔ not a defect
fix.
2. **The tightening has a blast radius of zero in this tree, measured.**
No directory matching `src/*/docs` exists anywhere under `examples/` or
`packages/` (the two example apps keep their flat `src/docs`), and the
`build-json-advisory-parity` fixture builds only `src/docs` — so no
existing build, fixture or gate changes behaviour.

## What changed

One warning per `src/PACKAGE/docs/` directory holding Markdown, raised
where the path is anchored (`collect-docs.ts`), so `os build`, `os
validate` **and** `os lint` all report it through the doc-issue channel
they already share — text face and `--json` `warnings` alike:

```
⚠ src/sales/docs: src/sales/docs/ holds 4 Markdown file(s) that were NOT collected: package
  docs are read from src/docs/ only (ADR-0046 §3.2), so these are absent from the artifact's
  `docs[]` and from every book that includes them. Move them into src/docs/ …  Found: …
    rule: docs/uncollected-directory
```

`severity: 'warning'`, not `'error'`, deliberately: an error fails the
build, and a `src/PACKAGE/docs/` directory is not declared anywhere the
build can read — the collector can only *guess* it was meant as ADR-0046
docs, and refusing a tree that is green today on a guess is worse than
the silence it replaces. The reasoning is in the function's own
docblock, not only here.

## Tests, and the ablation that proves they can fail

⚠️ `collect-docs.test.ts` was the pin most likely to go green on the
wrong fix — its fixture is built on the same `src/docs` join — so every
new case that asserts the warning **also** asserts what was collected. A
collector that silently returned `{ docs: [], issues: [] }` passes none
of them.

Ablation (one-shot proof, no test file left behind), run from the
committed state, at `6418bae2d`:

| leg | on-disk proof | result |
|:--|:--|:--|
| mutate: drop the probe call from `collectDocsFromSrc` |
`uncollectedDocsDirectories(srcDir)` occurrences 1 → 0, marker 1, blob
`366e74b5` ≠ HEAD `3b212703` | **4 failed / 36 passed** |
| restore: `git checkout HEAD -- packages/cli/src/utils/collect-docs.ts`
| blob back to `3b212703`, `git diff HEAD` empty, marker count 0 | **40
passed** |

The two cases that stay green under mutation are the negative controls —
they assert *absence* of a warning.

Evidence, all at `6418bae2d`:

- `pnpm exec turbo run build --filter=@objectstack/cli --concurrency=2`
:: exit 0 (57 tasks; the `^...` closure, required before any verdict —
`@objectstack/spec/system` is imported by the test file)
- `pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2` :: exit 0 — **208 files / 2982 tests passed**
- `pnpm --filter @objectstack/cli typecheck` :: exit 0
- `pnpm lint` :: exit 0 — the **whole** lane, not a narrowing (the
derivation never names it)
- `node scripts/pm/dispatch-gates.mjs --commands` → 62 families, all 62
run, reconciled with `--ran`: **62 derived, 62 run, 0 NOT-MEASURED, 0
UNRUN**

⚠️ One red, pre-existing and ⛔ not this PR's: `pnpm
check:cross-package-test-inputs` exits 1 wherever `packages/spec/dist`
is built. Its finding names
`packages/cli/test/init-created-files-summary.e2e.test.ts` descending
`packages/spec/dist/` — a file this diff does not touch. Already filed
as objectstack-ai#18353 / objectstack-ai#18348; ⛔ no fourth card.

The `packages/cli` **integration** tier is declared to CI: this diff
touches neither an integration-tier file nor a spawn entry (`bin/`,
`test/helpers/serve-process.ts`), so only the `unit` tier is owed
locally.

## Acceptance notes

- **The `compile.ts` step line was deliberately NOT touched**, though it
is on this card's declared file surface. Printed *before* the collection
it announces, it is the reassurance half of the defect, and reporting
the collected count after the call would be the honest form. But that
line is quoted verbatim in `content/docs/deployment/cli.mdx` inside a
fenced transcript block tagged `transcript=os-build`, and that page is
staked by in-flight sibling PR objectstack-ai#18416 — editing it in this window is the
breach the dispatch fence names. The change and the sentence it needs
are reported back to the PM to sequence behind objectstack-ai#18416, ⛔ not resolved
inside this diff.
- **The other half of the card — reading `src/PACKAGE/docs/` — is not
delivered**, for the contract reason measured above. It wants its own
card carrying the attachment question (which `packages[]` body, and
whose namespace prefix), ⛔ not a rider here.
- `os dev` / `os serve` drop the collector's issues entirely
(`serve.ts`, "collection only, never block boot"), so the dev server
stays silent about this loss where the build now speaks. Noted, not
filed: it is a deliberate, commented posture, and no build or artifact
is affected.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ne, and the arming echo is not the landing method (objectstack-ai#18713)

Fixes objectstack-ai#18461

`Clause-②: no`

One file: `.claude/skills/pm-dispatch/references/platform-readings.md`,
held at **466 / 466** (headroom 0,
widest line 120 B). No ceiling raised. Net line change **0**: one row
rewritten in place, one row added, one
payment taken in the same file. `skip-changeset` — `.claude/**` is
shipped by no package's `files[]`, so
nothing published moves.

Reserved rows are untouched and verified by content: `:10`–`:12` and
`:431` (objectstack-ai#18469 PR-A's pending patch),
`:209` (PR objectstack-ai#18666's one-line change, awaiting the maintainer — it rides
at `:208` after the payment, byte
identical), and the nine rows PR objectstack-ai#18689 adopted an hour before this
branch (`:28`, `:85`, `:249`, `:277`–`:278`,
`:291`, `:347`, `:357`, `:393`, `:410` — none appears in any hunk of
this diff).

## Row ② — rewritten in place at `:50`

Ruling reference: the maintainer's letter **A** on objectstack-ai#18421, comment
5716260764 (2026-09-17T14:41Z, 「同意」).
Its operative conclusion is the one this row carries:
**`auto_merge.merge_method` is not a reading of what will
land** — the landing shape is the merge queue's to decide.

**before** (120 B, the falsified form):

```text
- 挂上的 auto-merge 存的方法恒为 `merge`,不论请求了什么;REST `auto_merge.merge_method` 读回 `merge`。
```

**after** (117 B):

```text
- auto-merge 回读 `merge_method` 不恒定:同 `SQUASH` 载荷 `merge`/`squash` 皆现,⛔ 非落地方法判据。
```

**Why 「不恒定」 and not either constant.** 「恒为 `merge`」 is falsified by this
card's four PRs; 「恒为
`squash`」 is falsified by the same table's first row and by the ruling's
own measurement. Same endpoint
(`PUT .../pulls/{n}/ccr/auto_merge`), same `{"merge_method":"SQUASH"}`
payload:

| PR | echoed `merge_method` | landed as |
|---|---|---|
| objectstack-ai#18395 | `merge` | single-parent squash |
| objectstack-ai#18392 | `squash` | single-parent squash |
| objectstack-ai#18416 | `squash` | single-parent squash |
| objectstack-ai#18445 | `squash` | single-parent squash |

The objectstack-ai#18421 ruling measured the same endpoint answering `merge` (both
arming channels, MCP and CCR), and the
dispatching seat's own arms today echoed `squash` on PR objectstack-ai#18689 / objectstack-ai#18690
/ objectstack-ai#18700 and `merge` on earlier ones.
Four landings, two echoes, one landing shape ⇒ the echo varies and
decides nothing. A row asserting either
constant would teach a seat to investigate a read-back that is simply
not a signal.

**Neighbours left as they are.** `:49` is about the bare `PATCH` draft
bit — unrelated. `:51`
(「仓库 `allow_merge_commit:false` 时同样读回 `merge`」) does not restate 「恒」: it
is one conditioned
measurement, and 「同样」 now attaches to `merge`, which the rewritten row
names as one of the two observed
values. `:53`–`:55` already carry the landing half (the branch rule
decides; three carriers agreeing proves
nothing) and are untouched — the row above them no longer contradicts
them.

## Row ① — added at `:272`

**after** (118 B):

```text
- 多标签页还静默截断:`totalCount` 231 而 `returned` 30,单标签同车道 24/24 ⇒ 求交读成空车道。
```

**Placement — a declared deviation from the dispatch.** The dispatch
named the `:247`–`:248` neighbourhood
(the MCP `list_issues` rows). The OR half of this reading is already in
the file, in the 读数陷阱 cluster at
`:269`–`:274`: `:269` 「`labels` 数组是并集」, `:270` 「⛔ 永不读作交集」, `:272`
「失效全静默」, `:273`
「正确读法 = 整车道单标签一次读全加本地对 labels 求交」. Writing the new row at `:247` would
have put a second
home for the labels-OR fact six rows away from the first — the same
duplication the dispatch forbids for row ②
(one row for one fact, never two). So the row is placed where the fact
it compounds already lives: directly
between the silent-failure row `:272` and the prescription `:273`, which
it is the reason for. The fact
delivered is unchanged; only the address is.

**What the row adds over `:269`–`:274`.** Those rows carry the OR
semantics and the prescription. They do not
carry the **truncation**, and the two compound: the returned page is a
fraction of the set with nothing in the
response saying which fraction, so a local intersection taken over *that
page* can return zero and read as
「本车道无活」. The measurement is inside the row:
`labels=["domain:cli","pm:queue"]` answered `totalCount=231`
with `returned=30`, while the single-label query on the same lane in the
same minute answered 24 / 24 —
complete, and intersectable. That is what makes `:273` load-bearing
rather than a style preference.

## The payment — one dedup, `:202`+`:203` become one row

| payment | before | after | fact lost |
|---|---|---|---|
| `:202`+`:203` | 「换道:探针绿走 REST 列表端点 `GET
/repos/{o}/{r}/issues?state=open&labels=a,b&per_page=N`。」 + 「它走 core 桶且
`labels` 是真 AND;⛔ 完整性自证靠 `&page=N` 加总数核对。」 | 「换道:探针绿走 REST 列表端点列卡,走 core
桶且 `labels` 真 AND;拼写与自证见 `rest-channel.md`。」 (118 B) | the inline
endpoint spelling and the `&page=N`-plus-total spelling — both held
verbatim in `rest-channel.md` 〈读侧〉, which `:137` already names as their
single home (「逐操作通道归属、写侧配方与队列路由三读法见 `rest-channel.md`,⛔ 不在本表复述」). The
two facts the row keeps inline are the ones a seat needs before it
switches channel; `labels` being a true AND on REST is also stated
in-file at `:274`, and the core bucket at `:115` / `:128`. |

Net: 3 lines changed, 466 in, 466 out.

## Gate readings

`node scripts/pm/check-governed-merges.mjs --test
.claude/skills/pm-dispatch/references/platform-readings.md`
exits **3 = GOVERNED** (`.claude/**` ×1) — recorded as the verdict it
is. This PR stays draft; no seat flips it
ready, enqueues it or arms auto-merge. The references tier applies: the
skills seat's `## Contract review`
record goes on this thread, and the seat lands it.

- `pnpm check:pm-skill-ratchet` :: exit 0 — 「platform-readings.md is 466
lines (ceiling 466; headroom 0)」,
「widest table row is 0 bytes (pin 0; headroom 0)」; no line over 120 B
(`awk 'length > 120'` returns nothing).
- `pnpm check:skill-frame-sync` :: exit 0 — 「the one declared copy of
the decision frame is internally
  coherent … 4 axes … 74 markdown files scanned for undeclared copies」.
- `pnpm lint` (repo-wide, `eslint . --no-inline-config`) :: exit 0.
- `grep -naP` for control characters over the file: no hits. No
tag-shaped fragment in either new row.

### Derived gate list, with exit codes

Derived from the worktree with `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack`
(no hand-fed paths; change set 1 path vs merge base `72dd95fa5`), every
command run, each exit code captured by
redirect-then-`$?`:

```text
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/pm/check-governed-queue-guard.mjs --self-test          :: exit 0
node scripts/pm/check-harness-current.mjs --self-test               :: exit 0
pnpm --filter @objectstack/lint run check:doc-formula-expressions   :: exit 0
pnpm check:agent-test-spelling                                      :: exit 0
pnpm check:doc-authoring                                            :: exit 0
pnpm check:driver-memory-census                                     :: exit 0
pnpm check:nul-bytes                                                :: exit 0
pnpm check:pm-governed-merges                                       :: exit 0
pnpm check:pm-half-states                                           :: exit 0
pnpm check:pm-skill-id-lint                                         :: exit 0
pnpm check:pm-skill-ratchet                                         :: exit 0
pnpm check:refd-timer-probe                                         :: exit 0
pnpm check:required-contexts                                        :: exit 0
pnpm check:skill-frame-sync                                         :: exit 0
pnpm check:watch-hint-literal                                       :: exit 0
pnpm check:pm-settings-deny-roster                                  :: exit 0   (outside the derivation; run
                                                                                 because its roster lives under
                                                                                 .claude, which this path is in)
```

`check:doc-formula-expressions` first exited **3 — PREREQUISITE NOT
MET** (`@objectstack/formula` and
`@objectstack/lint` not built, 「Nothing was measured」). It was re-run to
a real verdict after
`pnpm exec turbo run build --filter=@objectstack/formula
--filter=@objectstack/lint` through
`scripts/pm/os-verify-lock.sh` (VERDICT command-exit 0, held 1s, waited
0s); only the exit 0 is recorded above.

Reconciliation: `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --ran ran.txt` →
「18 derived famil(ies) accounted for — 18 run, 0 NOT-MEASURED (a DERIVED
zero — all 18 recorded an exit code
and none of them is 3)」. Outside that union: the 53 artifact-roster
families, 11 wide-population families,
14 changeset-pending families and the CI-only families
(`--verify-required-set`, `check-half-states
--provenance`) — CI's, not this branch's, and named here so their
absence is not read as a clearance.

## Out of scope, reported not written

The card's third item — `scripts/pm/check-expected-skips.mjs` cannot run
in a seat container
(`ERR_MODULE_NOT_FOUND: Cannot find package 'yaml'`) — is not a
references line and is not written here. In this
worktree, after `pnpm install`, `yaml@2.9.0` is present; the crash is a
property of the container the seat runs
it from, not of the script.

_Generated by [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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants