Skip to content

docs(spec,formula): state the Expression contract in the present tense — drop the M9.1 / M9.2 phase language - #18557

Merged
os-bill merged 1 commit into
mainfrom
claude/issue-17849-drop-m9-phase-language
Sep 17, 2026
Merged

os-bill merged 1 commit into
mainfrom
claude/issue-17849-drop-m9-phase-language

Conversation

@os-bill

@os-bill os-bill commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Part of #17849

Clause-②: no

The spec-seat half of #17323, ruled B by the director seat (comment 5644710461, decision batch #123 item 1, 2026-09-12; maintainer verbatim 「同意」). The M9.1 / M9.2 phase language is dropped from the citation sites in packages/spec and packages/formula, and the contract each of them describes is restated in the present tense. ⛔ No accept-set change: no key is added, renamed, re-typed or retired, and every envelope that parsed before parses byte-identically.

The docs half (PR #17851, Part of #17323) merged on 2026-09-12T13:17:02Z, so content/docs/data-modeling/formulas.mdx and the two pending changesets already carry the new wording. This PR is the other half the ruling's item 4 assigns to this seat — packages/formula included, which the domain table calls domain:engine and the ruling puts here verbatim.

What the text says now

  • source is the canonical persisted form — it is what the engine evaluates.
  • ast is accepted beside it as an optional opaque structured value (each engine validates its own shape), and it carries no promise of becoming required.
  • A slot whose value the engine RUNS requires source; EvaluatedExpressionSchema is where that is spelled out.
file M9\.[12] hits at 79a046f after
packages/spec/src/shared/expression.zod.ts 7 0
packages/formula/src/cel-engine.ts 1 0
packages/formula/src/normalize.ts 1 0

Lit control for the same probe: ExpressionSchema in expression.zod.ts reads 19, so the zero is a reading and not a broken path.

cel-engine.ts's AST-only arm also carried an M9.7 promise ("revisit AST-only execution in M9.7 when we cut the spec persistence over") inside the same sentence as its M9.1 hit. It goes with the sentence rather than being left as a phase promise standing beside a de-phased one; it is named here rather than smuggled.

The verbatim-quote constraint, and the proof it holds

Coordination note 5645945565: the two pending changesets quote the exported constant EVALUATED_EXPRESSION_SOURCE_REQUIRED VERBATIM inside a blockquote, and the docs half already edited them to read 「(the canonical persisted form)」. The constant loses exactly the four words of phase M9.1 and nothing else, so the quote stays a quote. Proved mechanically rather than by eye — the constant's runtime value was reassembled from the source and compared with each changeset's blockquote, joined:

.changeset/flow-edge-condition-evaluated-slot.md              ==> BYTE-IDENTICAL to the constant
.changeset/blank-node-condition-refused-at-registration.md    ==> BYTE-IDENTICAL to the constant

No consumer parses that sentence: every call site imports the constant by name, and the message assertions in packages/lint, packages/services/service-automation and packages/spec read substrings that never contained the phase id — the non-blank-source clause and the cannot-evaluate-ast clause.

⚠️ Declared deviation — the packages/spec/CHANGELOG.md erratum is NOT in this PR

The card's work items and the ruling's item 2 also name an erratum line under the already-published ## 17.4.0 entry that promised M9.2 (3 hits at :1096, :1103, :1119). It is deliberately not carried here, because AGENTS.md's Documentation Guardrails row for packages/*/CHANGELOG.md is categorical about the packaging:

❌ Never edit in a code PR … Factual error in a released entry → amend that entry in a dedicated docs-only PR, ⛔ never an erratum in a later entry and never a rider on code changes

This PR is a code PR: it moves a published string and owes a patch changeset. The ruling names 「the #17026 shape」 for the erratum, and that shape — read off the card #17026 and PR #17896 that executed it — is a dedicated docs-only PR with skip-changeset, whose executing seat quoted this same AGENTS.md row verbatim as its authorization chain. So the ruling and the guardrail agree; only the card's file-surface bundling puts them in one PR.

⇒ The erratum is owed as a separate docs-only PR, and #17849 stays open for it — which is why this body reads Part of #17849 and not the closing form the dispatch asked for. The residual after this PR, measured on this branch:

$ git grep -lE 'M9\.[12]' -- packages
packages/spec/CHANGELOG.md          # 3 hits — the erratum's target, owed as a docs-only PR

Nothing under content/ remains; the docs half cleared it.

Verification

Everything below is a real local run, exit codes landed to disk before reading (⛔ never through a pipe).

run verdict
pnpm --filter @objectstack/spec --filter @objectstack/formula build VERDICT command-exit 0 (shared verify lock)
pnpm --filter @objectstack/spec check:generated exit 0 — ✓ all 15 generated artifacts up to date (nothing to regenerate: no .describe() moved)
pnpm --filter @objectstack/spec --filter @objectstack/formula typecheck exit 0
pnpm --filter @objectstack/spec --filter @objectstack/formula test exit 0 — spec 483 files / 13773 tests, formula 29 files / 856 tests
pnpm lint (eslint . --no-inline-config, whole repo) exit 0
derived gate families (dispatch-gates.mjs --repo objectstack-ai/objectstack --commands) 78 derived · 76 run green · 2 NOT MEASURED

--ran reconciliation with exit codes recorded per family: 78 derived famil(ies) accounted for — 76 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3). The two are check:dual-build-cjs-loads and check:lean-entry-closure, both exit 3 = PREREQUISITE NOT MET (they read the dist/ of ~80 packages this tree never built) — ⛔ neither a pass nor a failure; CI's Build Core is where they run.

Two families needed a prerequisite before they could answer, and both are green once given it: check:doc-formula-expressions (exit 3 until @objectstack/lint was built, then exit 0) and check-plugin-teardown-shape --self-test (exit 1 on this shallow clone until its pinned fixture commit was fetched, then exit 0 / 47 cases).

Acceptance notes

⚠️ One gate reds on this tree and it is NOT this diffpnpm check:cross-package-test-inputs (exit 1), flagging @objectstack/cli descends a directory tree from packages/spec/dist/, rooted in packages/cli/test/init-created-files-summary.e2e.test.ts. Ablated to the cause on a pristine origin/main worktree with zero diff:

node scripts/check-cross-package-test-inputs.mjs          -> exit 0  (OK: 29 package(s) … 13 walked root(s) judged)
mkdir -p packages/spec/dist  &&  (same command)           -> exit 1  (the identical failure text)
rm -rf packages/spec/dist    &&  (same command)           -> exit 0  (restored; tree clean)

⇒ the verdict turns on whether the gitignored packages/spec/dist exists, not on any source change — and building packages/spec is exactly what AGENTS.md requires before the spec artifact gates run. CI cannot see it: Lint & Repo Gates installs and never builds, so the gate is green there. Filed as a finding for the dispatching seat rather than fixed here — packages/cli and scripts/check-cross-package-test-inputs.mjs are both outside this card's file surface.

Noted, not filed: nothing else. The three source files were read whole and carry no other stale phase promise; the remaining M9.x mentions in the tree (M9.9b in default-value-shape.ts, action.zod.ts, objectql/engine.ts; M9.5 / M9.7 in objectql and the docs page) are ROADMAP section references outside the ruling's seven citation sites, and the ruling names only the M9.1 / M9.2 phase promise.


Generated by Claude Code

The M9.1 / M9.2 phase language is dropped from the six citation sites in
`packages/spec/src/shared/expression.zod.ts` and from the two in
`packages/formula`. `source` is the canonical persisted form; `ast` is an
optional opaque structured value with no promise of becoming required; an
evaluated slot requires `source`. No accept-set change.

`EVALUATED_EXPRESSION_SOURCE_REQUIRED` loses the four words "of phase M9.1"
and nothing else, which is exactly the wording the two pending changesets
that quote it verbatim already carry.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/formula/src/normalize.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/formula/src/normalize.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 137 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 79a046f8cdf085d95200826ee9bb2fa6584bc3d5packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1357dbd8e17560cd04f76edd79ccd818e8431a56 — the merge of head 62ca2d6c5e408d5f597743ca1eb4c13c6fb9797b into base 79a046f8cdf085d95200826ee9bb2fa6584bc3d5, 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 1357dbd8e17560cd04f76edd79ccd818e8431a56 && git checkout 1357dbd8e17560cd04f76edd79ccd818e8431a56
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 79a046f8cdf085d95200826ee9bb2fa6584bc3d5 62ca2d6c5e408d5f597743ca1eb4c13c6fb9797b && git checkout -B drift-repro 79a046f8cdf085d95200826ee9bb2fa6584bc3d5 && git merge --no-ff 62ca2d6c5e408d5f597743ca1eb4c13c6fb9797b

node scripts/docs-audit/affected-docs.mjs --json 79a046f8cdf085d95200826ee9bb2fa6584bc3d5

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tooling labels Sep 17, 2026
@os-bill
os-bill marked this pull request as ready for review September 17, 2026 01:58
@os-bill
os-bill added this pull request to the merge queue Sep 17, 2026
Merged via the queue into main with commit e08c8b0 Sep 17, 2026
37 checks passed
@os-bill
os-bill deleted the claude/issue-17849-drop-m9-phase-language branch September 17, 2026 02:23
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…red M9.2 promise (objectstack-ai#18569)

Part of objectstack-ai#17849

Clause-②: no

The second half of objectstack-ai#17849, split out of PR objectstack-ai#18557 because AGENTS.md's
Documentation Guardrails row for `packages/*/CHANGELOG.md` requires it:
an already-published entry is amended **in a dedicated docs-only PR**, ⛔
never as a rider on code changes. This diff is exactly one file and adds
no source, no schema and no export.

objectstack-ai#17323's ruling item 2 orders it: 「`packages/spec/CHANGELOG.md`: an
erratum line under the entry that promised M9.2 (the objectstack-ai#17026 shape —
correct the published text, note the date), ⛔ not a rewrite of history.」

## What was corrected, and where

One entry: `ef3a138` (`feat(spec)!: an evaluated expression slot
requires a non-blank source`) under the already-published `## 17.4.0`.
Line numbers re-derived on this tree at `79a046f8c`, ⛔ not carried over
from the card or from the earlier round:

| line | published text | disposition |
|--:|:--|:--|
| 1096 | the blockquote quoting `EVALUATED_EXPRESSION_SOURCE_REQUIRED`,
parenthetical `(the canonical persisted form of phase M9.1)` | **left
exactly as shipped** — it is a faithful quote of what 17.4.0 published.
A paragraph under it says what the constant reads now. |
| 1103 | 「its docblock declares that `ast` becomes required in build
output at phase M9.2」 | corrected in place, old words kept as a marked
quotation |
| 1119 | 「has no evaluable form under M9.1」 | corrected in place, old
words kept as a marked quotation |

One adjacent clause falsified by the same ruling is corrected in the
same stroke and **named here rather than smuggled**: the bullet also
read 「when AST-only evaluation lands」, which presupposes the retired
promise. It now reads 「if AST-only evaluation is ever chartered」 — the
wording the two pending changesets already carry. Leaving it would have
left a 「when it lands」 sitting beside 「no promise of becoming required」
in one bullet.

One dated erratum line closes the entry, carrying the in-repo tail this
repository already uses in five places (`packages/spec/CHANGELOG.md` ×3
at `:2457`, `:2882`, `:5277`, `packages/lint/CHANGELOG.md:1214`,
`packages/metadata-protocol/CHANGELOG.md:134`):

```
*Erratum, 2026-09-17 — the M9.1 / M9.2 phase promise this entry restated was retired by the
ruling on objectstack-ai#17323 (2026-09-12) … (Corrected after publication, objectstack-ai#17849.)*
```

⛔ No new entry at the top, ⛔ no version heading added (`git diff -U0 |
grep -c '^+## '` → 0), ⛔ nothing this release published is changed.

## ⚠️ The card's grep criterion cannot be met, and the ruling is why

The card sets `git grep -l 'M9\.[12]' origin/main -- packages` → 0.
After this PR that file still carries **four** hits, and every one of
them is required by the ruling's own 「⛔ not a rewrite of history」:

```
:1096  the as-shipped blockquote                       (deliberately untouched)
:1109  "…becomes required in build output at phase M9.2"   inside `As published, that sentence continued "…"`
:1130  "no evaluable form under M9.1"                      inside `As published that clause read "…"`
:1142  the erratum line itself, naming the retired promise
```

Not one of them is a live assertion of the promise — they are the
quotation marks the objectstack-ai#17026 shape puts around it, plus the erratum that
retires it. A zero would require deleting the published words, which is
precisely the rewrite the ruling forbids. ⇒ the criterion and the ruling
are not jointly satisfiable, and the ruling governs. Reported rather
than forced.

## ⚠️ `skip-changeset` — checked against objectstack-ai#18375 before relying on it

The label is applied. Before applying it I re-read the refusal objectstack-ai#18375 is
about, `scripts/check-empty-changeset.mjs`, and it does **not** reach
this PR:

- that gate has two rules and **both take the `.changeset/` diff and
nothing else** — rule 1 fires on a newly ADDED empty-frontmatter
changeset, rule 2 (`scanForeign()`) on a MODIFIED or DELETED changeset
that exists on the merge base;
- the DELIBERATE-CORRECTION text 「no label and no diff shape makes that
safe」 is scoped, in its own words, to 「the note you rewrote describes
behaviour THIS PR changed」 — a **pending release note**, i.e. a
`.changeset/*.md`;
- **this diff contains no `.changeset/` path at all** (`git diff
--name-only origin/main...HEAD` → one line,
`packages/spec/CHANGELOG.md`). So the label suppresses no refusal that
could have fired here, and the finding's hazard is absent rather than
accepted.

What the label IS doing is the documented job: `Check Changeset`
requires an added changeset from **every** PR, with no path filter, so
`skip-changeset` is the only instrument for a diff that releases nothing
of its own — the same instrument the objectstack-ai#17026 ruling named (item 3) and PR
objectstack-ai#17896 spent for the same shape.

## Verification

Exit codes landed to disk before reading, ⛔ never through a pipe.

| run | verdict |
|:--|:--|
| derived gate families for this one path | 55 derived · **51 run
green** · 4 NOT MEASURED |
| `--ran` reconciliation with per-family exit codes | `55 derived
famil(ies) accounted for — 51 run, 4 NOT-MEASURED (4 DERIVED from a
recorded exit 3)` |
| `check-release-section-coverage` (plain · `--self-test` · `--strict`)
| exit 0 ×3 — 7 published minors across 2 GA majors, every one still
covered |
| `pnpm check:release-notes`, `pnpm check:release-page-status` | exit 0
|
| `pnpm lint` (`eslint . --no-inline-config`, whole repo) | exit 0 |
| `pnpm check:nul-bytes` + a direct control-character sweep of the file
| exit 0 / no hits |

The 4 NOT MEASURED are `check:dts-closure`,
`check:dual-build-cjs-loads`, `check:lean-entry-closure` and
`check:sourcemap-no-sources-content` — all exit **3 = PREREQUISITE NOT
MET** (they read the `dist/` of packages this tree never built). ⛔
Neither a pass nor a failure; CI's `Build Core` runs them.

**No test is owed and that is measured, not assumed.** Four test files
name a `CHANGELOG.md` path and **every one of them excludes it**:
`compliance-families-retirement.test.ts` skips `CHANGELOG.md`, both
`action-owner-key-single-source.test.ts` files list it under `covers` as
「a published CHANGELOG is the record of the removal itself」, and
`template-consistency.test.ts` passes `:(exclude)**/CHANGELOG.md`. No
test reads the bytes this PR moves.

**The `changeset version` survival question is already answered and is
not re-run here.** objectstack-ai#17026's round measured it with a lit control: a
manual edit inside an already-compiled section SURVIVES `changeset
version` (marker present before and after, while the tool provably
re-wrote both files — a new version section prepended, the package
version advanced, 250 pending changesets consumed). That reading stands;
re-running it would buy nothing.

## Acceptance notes

Confirming reading for the finding recorded on objectstack-ai#17849: `pnpm
check:cross-package-test-inputs` is **green (exit 0)** on this tree,
which has no `packages/spec/dist` because a markdown-only diff needs no
build. Same gate, same commit base, opposite verdict from the built tree
— a third independent leg for the same cause. ⛔ Not filed again; it is
already objectstack-ai#18353 and objectstack-ai#18440.

Noted, not filed: nothing else. Successor for the `M9.9b` / `M9.5` /
`M9.7` ROADMAP citations elsewhere in the tree: none — no PR or person
is routed to those files by this work, and the ruling names only the
M9.1 / M9.2 phase promise.

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…L `visible` (objectstack-ai#17631) (objectstack-ai#18575)

Fixes objectstack-ai#17631

Clause-②: no

`lowerRequiresFeature` lowers the `requiresFeature: 'FLAG_NAME'` sugar
into the canonical `visible` CEL predicate, and its own docblock states
the ADR-0078 rule it enforces: a composition that could never take
effect is a loud parse error, not a silent one. The guard enforcing that
rule tested the **type** of `source` — `typeof existing.source !==
'string'` — so a whitespace-only `source`, legal on `ExpressionSchema`
(the persistence contract, whose `min(1)` whitespace clears), passed it
and the feature gate was composed **around a blank operand**. The guard
produced exactly the shape it exists to reject.

## Measured, this branch, on `f962be9d08`

The card's four-row probe on `ActionSchema`, reproduced before touching
anything — all four rows came out as filed:

| case | before (`f962be9d08`) | after |
|:---|:---|:---|
| ast-only `visible`, NO `requiresFeature` | `parses=true`, envelope
verbatim | unchanged |
| ast-only `visible`, WITH `requiresFeature` | `parses=false` (AST-only
refusal) | unchanged |
| blank-src `visible`, NO `requiresFeature` | `parses=true`,
`{"dialect":"cel","source":" "}` | **unchanged — the persistence
contract is not narrowed** |
| blank-src `visible`, WITH `requiresFeature` | `parses=true`, `source:
"( ) && features.organization != false"` | **`parses=false`, loud
refusal at `requiresFeature`** |

And the composed predicate, through `@objectstack/formula`
`celEngine.evaluate`, with a control leg differing only in the source:

```
"(   ) && record.x == 1"             ok=false  kind=parse  "Unexpected token: RPAREN"
"(record.x == 1) && record.x == 1"   ok=true   value=true        # CONTROL, identical composition, real source
```

So at render the gate faults instead of gating: fail-soft surfaces show
the element regardless of the flag, fail-closed surfaces hide it
regardless of the flag. Either way the flag decides nothing.

## The repair, and why it is this one and not the wider one

Two repairs were on the table and they differ in blast radius. This PR
takes the guard, **on the merits**, not because it is the smaller diff:

- **The defective string is produced here.** `( ) && features.X !=
false` is built by the composition step in `lowerRequiresFeature`.
Contract-first says refuse at the producer; this is the producer.
- **The invariant being restored is this function's own**, stated in its
docblock and cited to ADR-0078 one bullet above the leg that was
missing.
- **It covers every slot that composes the sugar at once** —
`ActionSchema.visible` and `ActionParamSchema.visible` today, and
whatever composes it next — because the rule lives in the shared
lowering rather than in either slot's declaration.
- **The wider repair would narrow something this card does not own.**
Composing `visible` onto `EvaluatedExpressionInputSchema` also refuses a
blank-`source` `visible` with **no** `requiresFeature` beside it — row 3
above, which parses clean today by design. objectstack-ai#17631's own filing says that
is one of the per-slot decisions objectstack-ai#15811's census enumerates and "may
want to travel with that family rather than alone".
- **And the `expression.zod.ts` spelling of it is refused by that file's
own declaration**, not merely by scope: `EvaluatedExpressionSchema`'s
docblock states "`ExpressionSchema` itself is NOT narrowed: it is the
persistence contract". The evaluated-slot rule already exists there as
`EvaluatedExpressionInputSchema`; nothing in that file needed to move,
and nothing in it did. It was declared read-only for this card (in
flight as objectstack-ai#17849 / PR objectstack-ai#18557) and was read, never edited.

## What changed

- `packages/spec/src/kernel/public-auth-features.ts` — the lowering
refuses a `source` that is blank after trimming, on the same leg as the
AST-only refusal above it. The notion of blank is `source.trim()`, the
one the engine's own helpers apply, so a `source` merely **padded**
around real text still composes verbatim. The refusal names the
composition it would have produced and both exits (drop the blank
`visible` and the sugar emits the gate alone; or put the predicate the
gate should compose with in `source`).
- `packages/spec/src/kernel/public-auth-features.test.ts` — the new
refusal is pinned in the same shape as the AST-only pin beside it (issue
**kind** and **subject**, never the wording), across three blank
spellings, plus the assertion that no envelope is produced. A second pin
fixes the boundary the refusal must not cross: a padded-but-authored
source still composes.
- `.changeset/17631-requires-feature-blank-source.md` — `patch`.

## Reverse verification

Committed first, then mutated, then restored — the mutation proven on
disk by occurrence count and by blob hash, the restore proven by blob
hash equality with `HEAD` plus an empty `git diff HEAD`, under a `trap
... EXIT INT TERM` with absolute paths. The test resolves its subject
through a **relative `./public-auth-features` import**, not through the
package `exports`, so no rebuild sits between the edit and the verdict.

Mutation: `if (existing.source.trim().length === 0) {` -> `if (false) {`
(the pre-fix behaviour). Predicted direction: red.

```
anchor occurrences BEFORE mutation: 1
anchor occurrences AFTER mutation: old=0 new=1
MUTATED_BLOB=81814bc9d6e408eae96650bac701fda5ee38841f (differs from HEAD: yes)

 FAIL  src/kernel/public-auth-features.test.ts > lowerRequiresFeature
       > rejects a CEL visible whose source is blank after trimming (ADR-0078)
 AssertionError: expected [] to have a length of 1 but got +0
 Test Files  1 failed (1)
      Tests  1 failed | 23 passed (24)

RESTORED ok (blob 59e27e1, git diff HEAD empty)
```

Exactly the new pin went red and the other 23 stayed green, so the
mutation is targeted and the pin is discriminating.

## One gate finding this change caused, and where it was fixed

The refusal was first written interpolating the composed gate. That put
`featureGatePredicate` — and through it the whole `PUBLIC_AUTH_FEATURES`
registry — in a customer-facing `message:` position, and
`check:doc-authoring`'s per-module fixed point then swept that
registry's **internal** `notes` / `exempt.reason` prose as
customer-facing text, flagging three pre-existing strings (`:197`,
`:220`, `:230`).

Measured with a control leg rather than assumed: the same gate is
**green** with `packages/spec/src/kernel/public-auth-features.ts`
reverted to `f962be9d08` (blob equality asserted both ways), and red
with the interpolation in. So the red was this diff's, not a
pre-existing one.

Fixed at the cause — the sentence now names the composed shape without
interpolating the gate, which is also how the two refusals beside it are
written — rather than by editing three unrelated strings in the
registry. `pnpm check:doc-authoring` is green again: 15502
customer-facing strings across 993 spec sources clean.

## Acceptance notes

- **Noted, not filed** — `check:doc-authoring`'s hoisted-const fixed
point is a per-module over-approximation: one field of a const reaching
a `message:` position (`semantics`, here) makes every string in that
const customer-facing, including registry notes no customer can ever
see. The gate's own output declares this spelling ("hoisted text-sink
consts (fixed-point, per module)"), so it is designed behaviour rather
than a contract violation, and it is cheap to work around at the call
site. Successor: none identified — no queued card touches this gate's
climb.
- **Declared gap, not a deviation hidden** — the AST-only leg is pinned
twice, in `kernel/public-auth-features.test.ts` and in
`ui/action.test.ts`. This PR adds the first of those two.
`ui/action.test.ts` is declared read-only for this card, so the
schema-level sibling pin is not in this diff; the schema-level behaviour
is measured in the table above instead. A seat holding that file can add
it in one `it` block.

## Gates

Derived for the actual changed paths with `node
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` (three-dot against the merge base; committed + working tree
+ untracked), then reconciled with `--ran` carrying every exit code.
Each exit code landed to disk before being read — never through a pipe.

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

- **82 of 83 green**, including `check:api-surface`,
`check:authorable-surface`, `check:docs`, `check:liveness`,
`check:adr-0087-registration`, `check:changeset-no-major`,
`check:nul-bytes`, `check:doc-authoring`, `check:type-check-coverage`,
`check:type-check-debt`, `check:test-source-alias`,
`check:dispatcher-error-vocabulary`, `check:where-matcher`.
- Four of them first answered **exit 3 (PREREQUISITE NOT MET — not a
finding)** because they read built output:
`check:doc-formula-expressions`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure`, `check:type-check-debt`. The workspace
closure was built (`turbo run build --filter='./packages/*'
--filter='./packages/*/*'`, 72/72 successful) and all four then exited
0. They are measured, not declared away.
- `pnpm --filter @objectstack/spec typecheck` green; `pnpm --filter
@objectstack/spec test` green — **483 files, 13775 tests** — both re-run
at the final head, after the refusal sentence changed.
- `pnpm --filter @objectstack/spec check:generated` — **all 15 generated
artifacts up to date**, nothing to regenerate.
- `pnpm lint` (`eslint . --no-inline-config`, the whole repo, no
narrowing) green at `ec0eaed2d2`, the final commit.
- `node scripts/pm/check-clause2-carriers.mjs --pair 18575` — **exit
0**: the clause-② declaration is readable in the fixed spelling, both
carriers agree, and the diff carries no widening tell. No
`needs:contract-review` label is on this PR; hanging or clearing it is
the seat's, not this PR's.
- The one red: `pnpm check:cross-package-test-inputs` **exit 1**,
signature `@objectstack/cli descends a directory tree from
packages/spec/dist/`. That is the known defect already carried by
**objectstack-ai#18353** and **objectstack-ai#18440** — it exits 1 on any tree where `packages/spec`
has been built and 0 when it has not, ablated both ways by two devs this
session. Not filed again, and unrelated to this diff.

## 维护者速读(草稿)

**改了什么** —— `requiresFeature` 这个语法糖在和一个 `source` 只有空白字符的 `visible`
组合时,会生成 `( ) && features.X != false`
这种任何作用域下都解析不了的谓词。现在它当场报错拒绝,而不是悄悄合成一个永远跑不起来的门。

**为什么改** —— 这正是 `lowerRequiresFeature` 自己文档里援引 ADR-0078
要拒绝的形态:声明解析通过、却什么都不改变。作者以为加了特性开关,实际上渲染时谓词直接解析失败 ——
容错的界面把元素照常显示出来(开关形同虚设),严格的界面无论开关如何都把它藏起来。两种结局都是开关不起作用。

**风险与代价(含回滚)** ——
这是一次接受集收窄:今天能解析通过的一种写法,以后会被拒绝。但被拒绝的那种写法在运行时本来就必然失败,所以没有任何能正常工作的写法被破坏,也就没有迁移负担
—— 拒绝信息本身就是处方。`ExpressionSchema` /
`ExpressionInputSchema`(持久化契约)一个字节没动,不带 `requiresFeature` 的空白 `source`
照旧解析通过。回滚 = 撤销这个 PR,没有数据迁移、没有生成物、没有已发布的注册表条目参与。

**席位意见** ——

**你要做的** —— 无需操作。这是一个 `patch` 级修复,不触及受管面,按常规队列落地即可。


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

---------

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/s tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants