docs(spec): name the node slot in the structural-condition ruling and its ADR-0087 entry - #17761
Conversation
… its ADR-0087 entry The blank structural condition is refused at all three doors since #17322 and #17495; two places in `packages/spec` still described the world before that. `structuralConditionRefusal`'s docblock admitted a whitespace-only string on #15662's ground that it "is consistent on both sides and is ruled correct, not a defect". #15807 removed that ground at the edge door and #17322 ruled on the disagreement. The admission itself is unchanged and still correct — this function answers the SHAPE question and the blank is refused beside it by the imported evaluated-slot rule — so what changes is the recorded reason, plus a section recording, without answering, the open question at the sibling ledger `predicate` slots. The ADR-0087 D3 entry `flow-edge-condition-evaluated-slot-source-required` named only `edges[].condition` in its `surface` and `acceptanceCriteria`, so a consumer replaying the chain was told to sweep the edge key alone and a deployment carrying a blank `config.condition` was never told to look. Both fields now name both structural slots, with the node key's own locator phrase. `registry.ts` is regenerated by `pnpm --filter @objectstack/spec gen:migration-registry`. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
… its assertions The same stale ground as the docblock, three lines from the symbol: the case admitted a blank structural condition as "not authored on both sides", which #15807 and #17322 ended. The two assertions are unchanged and still correct — they pin that this function does NOT carry the blank rule — and the comment now says so instead of restating a ruling that no longer holds. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
📓 Docs Drift CheckThis PR changes 1 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 70e3731293fd1ce6db29faee0cf157a329d95818 && git checkout 70e3731293fd1ce6db29faee0cf157a329d95818
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ec739e67192ce686f956cd490b7bc2b9d0d64cac 5a77c75b29637540fcb3acdc4a2078049767659d && git checkout -B drift-repro ec739e67192ce686f956cd490b7bc2b9d0d64cac && git merge --no-ff 5a77c75b29637540fcb3acdc4a2078049767659d
node scripts/docs-audit/affected-docs.mjs --json ec739e67192ce686f956cd490b7bc2b9d0d64cac
|
…bjectstack-ai#17776) Fixes objectstack-ai#17279 ## The defect PR objectstack-ai#15724 closed objectstack-ai#15627 by widening a `type-surface-only` reference to a **dotted member path**, so an author can name a nested member instead of a bare identifier. The walker it added narrows each leading segment through an **object literal only**. A member declared on a **class** therefore has no dotted spelling, and the bare fallback is "the first same-named definition in the file" — which is the defect objectstack-ai#15627 was filed on, surviving its own fix on the container kind that fix did not cover. Reproduced at the merge base on the card's own evidence, `packages/objectql/src/engine.ts` (read-only here, not edited): | spelling | before | after | |---|---|---| | `packages/objectql/src/engine.ts#delete` (bare) | `Promise<boolean \| number>` — `ObjectQL.delete`, which the card's diff never touched | unchanged | | `packages/objectql/src/engine.ts#ObjectRepository.delete` | refused: ``no `ObjectRepository` object literal (`ObjectRepository: {` or `ObjectRepository = {`) is declared at the top of the file`` | `Promise<any>` | | `packages/objectql/src/engine.ts#ObjectRepository.findOne` | same refusal | `Promise<Record<string, any> \| null>` | | `packages/objectql/src/engine.ts#ObjectRepository.update` | same refusal | `Promise<Record<string, any> \| number \| null>` | The first two rows are the load-bearing pair: the two spellings reach **different members** (`Promise<boolean | number>` vs `Promise<any>`), so the bare one is not a usable substitute — it answers a true sentence about the wrong member. **Lit control for that probe:** the object-literal case objectstack-ai#15724 rescued, `packages/client/src/index.ts#oauth.applications.get`, resolves to `Promise<OAuthApplication>` both before and after. A probe that found nothing for classes was one that could find something for literals. ## ⭐ The population, established before fixing (triage's gate) Triage asked how many published narrowings sit on class members today, and made the answer decide how this PR is written. **It is not zero.** Method: enumerate every `adr-0087: not-required (type-surface-only …)` marker in the tree with `git grep` (markers are copied verbatim from `.changeset/*.md` into each package's `CHANGELOG.md`, so the tree at HEAD carries the published ones too), split the comma-separated reference lists, then classify each named symbol's innermost container by walking class / interface / enum bodies and named object literals over the repo's own comment- and literal-masked projection. **Space searched:** every tracked file at HEAD — which is where markers live: `.changeset/*.md` (4 files), `packages/*/CHANGELOG.md` (4 files), `docs/adr/0087-*.md` and this gate's own fixtures. Placeholder references (`path/to/file.ts#Symbol` and the angle-bracket forms) were excluded. **Result — 42 real references:** | container of the named symbol | count | |---|---| | object literal | 30 | | **class** | **10** | | top level | 1 | | not a member definition (a top-level type name) | 1 | The ten class members: - `packages/drivers/driver-sql/src/sql-driver.ts#aggregate`, `#bulkCreate`, `#create`, `#findOne`, `#update` — all on `class SqlDriver` - `packages/drivers/driver-turso/src/turso-driver.ts#aggregate`, `#bulkCreate`, `#create`, `#findOne`, `#update` — all on `class TursoDriver` ⭐ **Every one of the ten is written BARELY, and not one of them could have been written dotted.** Each resolves today only because that name happens to be unique in its file (measured: 1 same-named definition each); the dotted spelling `SqlDriver.findOne` was refused for all ten with the object-literal message. So the category has been in live use on class members all along, addressable only by an accident of naming — and on the one file where the accident does not hold, `engine.ts`, the member had no spelling at all. **Lit controls for the census probe** (a zero is a reading only if the instrument could have come back the other way): - class: `packages/objectql/src/engine.ts#findOne` → 2 definitions, `class ObjectQL` (L9844) and `class ObjectRepository` (L15072). Lit. - object literal: `packages/client/src/index.ts#oauth.applications.get` → 14 definitions across nested literals inside `class ObjectStackClient`. Lit. - top level: `scripts/check-adr-0087-registration.mjs#parseSymbolRef` → 1, TOP LEVEL. Lit. ⇒ The gap is **live, not latent**. This PR unblocks the `engine.ts` shape outright, and gives the ten existing references a spelling that survives a same-named member being added above them. ## The change `resolveMemberPath` walks each leading segment through `containerBodiesFor`, the union of `objectLiteralBodiesFor` and the new `classBodiesFor`. A union, not two passes: a segment naming **one literal and one class** is AMBIGUOUS exactly as two literals are, and counting the kinds separately would let it through as "one of each". - `class`, `export class`, `export default class`, `declare` and `abstract` prefixes; the body is found with the existing `declarationBodyStart`, so `extends Base<{ a: 1 }>` does not send the walk into a type argument. - A **class expression** (`const X = class { … }`) is deliberately not walked — that name belongs to the binding, not the class. - `definitionsAtTopDepth` now treats any nested container as deeper nesting, so the objectstack-ai#16571 top-depth rule holds inside a class body too. - Refusal texts and the author-facing `fix:` remedy name both container shapes, and the remedy now prints a class example beside the literal one. An author who cannot see the spelling in the remedy cannot write it. **Bare references are untouched.** Nothing about what the marker means changed, and nothing beyond naming a class member was widened. ## ⭐ Ablation — it can fail, and it still refuses what it should Removing the class limb from the union (`containerBodiesFor` back to object literals only), proven on disk before reading any result — anchor occurrences 1 → 0, blob `394fd83b` → `c3a303c3`, restored afterwards and verified byte-identical to `HEAD` — turns **9 assertions red**: - `TSO-C1` reads back the exact original refusal: ``no `ObjectRepository` object literal or class (…) is declared at the top of the file.`` - `TSO-C12` (end to end, through `scan()`) fails as ``[predicate 4] cannot be resolved at HEAD in packages/objectql/src/engine.ts``. - ⭐ `TSO-C9`, the union pin, does not merely fail — it **silently resolves**: `dual.findOne` returns `Promise<Lit>` instead of refusing, i.e. the ablated walker picks one of two real candidates. That is the "writable but wrong" reference the whole dotted grammar exists to prevent, and it is why the union is counted as one set. Still refused **after** the change, so the gate did not simply become permissive: - `NoSuchClass.findOne` → ``no `NoSuchClass` object literal or class (`NoSuchClass: {`, `NoSuchClass = {` or `class NoSuchClass {`) is declared at the top of the file`` (and end to end, `TSO-C14`). - `ObjectRepository.nosuch` → ``no `nosuch` definition sits inside it`` — never resolved outward to the same-named member on the other class. - two classes of one name → ``opens 2 classes …, so the path is AMBIGUOUS``. - one literal + one class → ``opens 2 object literals and class bodies …, so the path is AMBIGUOUS``. - a class expression → refused, never guessed. **Whole-tree regression control:** all **42 of 42** live references read **byte-identically** before and after — 0 moved, 0 newly resolved. The ten bare class-member references in particular did not move. ## Changeset — measured, not assumed `skip-changeset`. Measurement, with both controls, on a real build: - **Subject:** the new identifiers `containerBodiesFor` / `classBodiesFor` / `describeBodies` / `BODY_PLURALS` occur 3 / 2 / 2 / 2 times in the changed source and **0 times** in any built `dist`. - **Positive control:** `OpenAIEmbedder`, a symbol that ships → **58** occurrences in `packages/plugins/embedder-openai/dist`. The probe finds shipped text. - **Negative control:** `createOpenAIEmbedder presets`, which exists only in that package's test file → 1 in `src/__tests__`, **0** in `dist`. The probe separates shipped from unshipped. - **Structural half:** the root `package.json` is `private: true`, and **0 of 70** non-private manifests has a directory containing either changed path, so no `files[]` can reach `scripts/**` or `docs/adr/**`. ⇒ Nothing published moves. - **Clause-②: no** — this PR puts no new key on any published payload. ## ⛔ Governed surface — the maintainer merges this by hand The diff touches `docs/adr/0087-metadata-protocol-upgrade-contract.md`, and `docs/adr/**` is in today's register (`docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md`). The ADR edit is not optional: the anchor record for this script states *"Never widen the exemptions here without the ADR half in the same PR"*, and the addendum described the walk as object-literal nesting. ⛔ No seat merges, queues, or arms auto-merge on this PR. ## 维护者速读(草稿) **改了什么** — `type-surface-only` 标记里的点分成员路径,原先只能穿过对象字面量;现在也能穿过类体,于是声明在类方法上的类型收窄终于能在它自己的标记里被点名。裸标识符的含义一个字节都没动。 **为什么改** — 这是 objectstack-ai#15627 的残留:它的修复只覆盖了一种容器。树上已有 **10 条**活的引用落在类成员上(`SqlDriver` / `TursoDriver` 各 5 条),全部靠"这个名字在该文件里恰好唯一"才能解析;一旦不唯一(`engine.ts` 就是),作者只剩两条路——写一个指向别的成员的错标记,或者丢掉 `**BREAKING**` 去躲开判据。后者正是 objectstack-ai#13080 记录的侵蚀。 **风险与代价(含回滚)** — 风险面是这一个门禁脚本的解析行为。全树 42 条现存引用改动前后逐条比对**完全一致**,消融显示去掉类分支会让 9 条断言转红、其中 1 条会变成"静默挑一个"。回滚 = revert 本 PR,门禁回到今天的行为,无数据迁移、无发布物变动。 **席位意见** — (留空,待维护者) **你要做的** — 本 PR 触及 `docs/adr/**`(受管面),⛔ 不进合并队列、不开自动合并,需要你手动合并或给出授权的 APPROVED 评审。 ## Verification - `node scripts/check-adr-0087-registration.mjs --self-test` → **355 assertions**, exit 0 (338 at the merge base; +17 in the new `TSO-C` battery, registered at its floor in `SELF_TEST_BATTERIES`). - `node scripts/check-adr-0087-registration.mjs --base origin/main` → exit 0. - Derived family via `node scripts/pm/dispatch-gates.mjs --commands` on the merged head: **43 commands — 42 run and green, 1 NOT MEASURED.** - `check:scripts-symbol-anchors` caught a real regression of mine (abbreviated `engine.ts#findOne` prose read as a live anchor) — fixed in its own commit and re-run green. - `check:doc-formula-expressions` first exited **3 (PREREQUISITE NOT MET — nothing measured)** because `@objectstack/formula` was unbuilt; after `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` it exits 0. - ⊘ **NOT MEASURED: `pnpm check:pm-dispatch-gates`** — its self-test did not reach a verdict inside this container's foreground window across four attempts (still running after 11 minutes, no output advancing), so it is **declared to CI** rather than reported as green. It grades the dispatch-gates checker's own fixtures, not this diff, and this diff touches no `scripts/pm/` path. ⛔ Read this as unmeasured, not as passed. - `main` merged once (`0a88a800bd`, which brought PR objectstack-ai#17761's `packages/spec/src/migrations/**` changeset) and everything above re-measured on the merged head: self-test 355/0, census unchanged at 10 of 42, whole-tree comparison still 42 of 42 identical. --- _Generated by [Claude Code](https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #17493
Items ① and ② of the card. ⛔ Item ③ is a ruling, not a refactor — it is measured and reported here, and deliberately not implemented; the card stays open on it, and this PR therefore carries no closing keyword.
Nothing in this diff changes behaviour. The refusal already shipped (#17322 at
registerFlow, #17495 atobjectstack validate); what shipped with it was the notification, and this is that half.① The on-site ruling that had become false
packages/spec/src/automation/flow-node-expression-paths.ts— the docblock ofstructuralConditionRefusal.It admitted a whitespace-only string on this ground, verbatim as it stood:
#15807 removed that ground (
FlowEdgeSchema.conditioncomposesEvaluatedExpressionInputSchema), #17322 ruled on the disagreement that left, and #17495 finished it at the third door. ⛔ Not deleted — it records a real decision, so the edit says what changed it:EVALUATED_EXPRESSION_SOURCE_REQUIREDand notSTRUCTURAL_CONDITION_SHAPE_REFUSAL.flow-node-expression-paths.test.ts's own case comment ("not authored on both sides and stays so"). Same defect class, same file face, mechanical: the comment is corrected, both assertions untouched. ⭐ Declared as a bounded on-site fix beyond the dispatched file face.② The ADR-0087 entry named only the edge slot
packages/spec/src/migrations/entries/semantic/18.flow-edge-condition-evaluated-slot-source-required.ts—surfaceandacceptanceCriteria.Both named only
edges[].condition, so a consumer replaying the chain was told to sweep the edge key alone and a deployment carrying a blankconfig.conditionon a node was never told to look. Both now name both structural slots, plus the node key's own locator phrase (the one the structural pass builds, e.g.node 'gate' (start) condition) beside the edge'sflows.N.edges.N.condition, and the sweep carries the warning that deleting aconditionfrom astartnode opens the trigger gate rather than preserving it.id,replacementandreasonare untouched, per the card.surface— measured, 0 (build-upgrade-guide.tsrenders it inside a code span).blank-node-condition-refused-at-registration.md,validate-refuses-blank-structural-condition.md) already declared by taking thenot-required (already-registered …)disposition against this very entry.packages/spec/src/migrations/registry.tsis regenerated by the repo's own generator,pnpm --filter @objectstack/spec gen:migration-registry— ⛔ never hand-edited.check:migration-registryis green.⛔ ③ Measured, reported, NOT implemented
registerFlowstill ACCEPTS a whitespace-only string at both sibling ledgerpredicateslots, on this head, against controls that prove the probe reaches them:' '''config.conditions[].expressionPREDICATE_SLOT_STRING_REFUSALatconfig.conditions[0].expressionscreen.fields[].visibleWhenPREDICATE_SLOT_STRING_REFUSALatconfig.fields[0].visibleWhenconfig.condition(lit control — the slot #17322 DID narrow)evaluateCondition(' ')andevaluateCondition('')both answerfalse— the same silent dead branch. The current behaviour is pinned as correct by #15572 atpackages/services/service-automation/src/decision-predicate-envelope.test.ts(theitblock at:110–:114on this head; the card cited:113–:117fromaefbb07b2, a 3-line drift, the pin itself intact).⛔ Narrowing those slots re-judges that pin and moves a published accept-set: a ruling, not a refactor.
packages/services/**is untouched by this diff — 0 files.验收备注
packages/lint'spredicateSlotRefusal-side prose andflow-node-expression-paths.test.ts:189also rest on "consistent on both sides" — but for the ledger predicate slots that statement is still true (resolver skips the blank, evaluator answersfalse, and neither has been rebound). Noted, not filed: nothing to correct until item ③ is ruled on.packages/spec/src/migrations/registry.tsis a hot shared file: PR feat(spec)!: retire thescheduledcache-warmup strategy — the cron it selected left in this same major (ADR-0049) #17638 and PR feat(spec): refuse a duration key whose JSDoc names a unit its describe does not #17635 both regenerate it from different entry files. No conflict at this head;origin/mainmerged at5a77c75b29and the registry regenerated and re-verified after.Verification
Every run below is on the final head
5a77c75b29(origin/mainmerged at that commit), with the gate's own verdict line, never a bare exit status read through a pipe.pnpm --filter @objectstack/spec build && check:generated && typecheck && test— VERDICTcommand-exit 0; 473 test files / 13436 tests passed.pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/automation/flow-node-expression-paths.test.ts— 1 file / 26 tests passed.node scripts/pm/dispatch-gates.mjs --commandsderived 84 gate families for this change set; 84 run, 82 green, 0 unrun — reconciled with--ran, exit codes captured before any pipe. The two non-green exited 3 = PREREQUISITE NOT MET, i.e. NOT MEASURED and not a finding:check:dual-build-cjs-loadsandcheck:type-check-debtboth refuse without a repo-wide build closure, and both are declared to CI, which builds one.check:adr-0087-registration— exit 0, verdict: "this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)".check:migration-registryexit 0, verdict: "src/migrations/registry.ts is current (202 semantic, 167 retired-key, 178 retired-def)".check:spec-changesandcheck:upgrade-guideboth report their artifact up to date;check:docsreports 222 generated files in sync.eslint . --no-inline-config --format jsonover the whole repo, eslint's own population read from its JSON output: 6638 files, 0 errors, 0 warnings. No narrowing claimed and none needed.check:nul-bytesexit 0 (8441 text files), plus a control-character self-scan over every changed file: 0 hits.Changeset:
.changeset/17493-node-door-refusal-residues.md,@objectstack/spec: patch, card-scoped filename in the tree's prevailing spelling.Generated by Claude Code