Skip to content

fix(driver-memory)!: refuse an array comparand and compare Date comparands by time value - #16840

Merged
os-musk merged 5 commits into
mainfrom
claude/issue-16810-memory-matcher-array-date-comparand
Sep 8, 2026
Merged

fix(driver-memory)!: refuse an array comparand and compare Date comparands by time value#16840
os-musk merged 5 commits into
mainfrom
claude/issue-16810-memory-matcher-array-date-comparand

Conversation

@os-musk

@os-musk os-musk commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes #16810

checkCondition routed both Date and Array into value == condition. Between two objects == performs no conversion — it compares REFERENCES — so the arm was neither the "exact match" the comment two lines above claimed nor a match at all, and it failed closed and silently on a published driver that calls itself a Reference Implementation.

The fossil, read before the guard moved

Both comments, verbatim from the tree:

  • :251-252// Case A: Implicit Equality (e.g. status: 'active') / // If condition is a primitive or Date/Array (exact match), treat as equality.
  • :259-260// Loose equality to handle undefined/null mismatch or string/number coercion if desired. / // But stick to == for JS loose equality which is often convenient in weakly typed queries.

Triage's reading holds and I confirm it: every reason the author recorded for == is about PRIMITIVES — an undefined/null mismatch, string/number coercion, weakly typed convenience — and not one of them covers Date or Array, which :252 nonetheless routes in and calls exact. This is not a deliberate trade-off implemented badly; two adjacent comments describe different things and == on objects delivers neither. Those primitive reasons are still true, so == is KEPT for exactly the cases they name.

I found no fossil anywhere saying the current behaviour is deliberate. Searched: the ADRs (only docs/adr/0053-date-and-datetime-semantics.md names comparands, and about temporal storage forms, never about equality by reference), this package's filter-refusal.ts header and its recorded "deliberately NOT refused" list, and the spec door's own list of cases it does not rule.

Subject sweep — what I checked before editing

driver-memory carries a maintainer ruling on a different subject: #6915, row-level tenant isolation, disposition B, ruled 2026-08-12. I read memory-tenancy-guard.ts in full rather than assuming it was unrelated. It is a BOOT-time refusal keyed on deployment tenancy posture and object tenancy.enabled; it names no filter, no comparand and no matcher arm, and its own direction — refuse loudly rather than answer silently and wrongly — is the same direction as this change, not opposed to it. Nothing here reverses it.

Deliverable zero — the four probes, before and after, with a firing control

Run against THIS repo's matcher and, beside it, this package's live query path, on a declared schema. The control fires on both legs in every run: a scalar comparand that legitimately matches (true / 1 row) and its negative twin (false / 0 rows). Readings whose control did not fire were discarded — the first live-path run passed the schema in the wrong shape, its CONTROL-NEG returned a row, and it was re-taken rather than reported.

probe matcher before matcher after live path before live path after
CONTROL scalar match true true 1 1
CONTROL-NEG false false 0 0
P1 { tags: ['a','b'] } vs stored deep-equal array false refused 400 1 row refused 400
P2a { created_at: Date } vs stored equal-instant Date false true 1 1
P2b { created_at: Date } vs stored ISO string false true 1 1
P2c same Date OBJECT (reference identity) true true 1 1
P3a { tags: 'a' } vs stored ['a','b'] false false 1 1
P3b { tags: 'a,b' } vs stored ['a','b'] true true 0 0
P3c { tags: 'a' } vs stored ['a'] true true 1 1
P4a { tags: { $eq: ['a','b'] } } false refused 400 1 row refused 400
P4b { tags: { $ne: ['a','b'] } } true refused 400 0 rows refused 400
P5 { tags: { $in: ['a','z'] } } true true 1 1
P6 { created_at: { $gte: Date } } true true 1 1

Three readings the card and triage did not have:

  1. The live query path was never broken. On P1 it returned the row — mingo deep-equals arrays. So the two faces of one package answered one filter two ways, which is the #5240 / #5328 / #5347 shape this package has spent five cards removing. The card's premise (the matcher is wrong) holds; the parenthetical's premise about "the document stores" was true of this driver's live path and false of its matcher, which is why the correction below names dispositions rather than families.
  2. $eq / $ne carry the same defect, and $ne carries it in the WIDENING direction (P4b answered true, i.e. the row survives an exclusion the author wrote). Repaired with the same helper — one predicate must not answer two ways depending on which spelling was used.
  3. Triage's addition survives untouched. P3a/P3b/P3c are byte-identical before and after, and are now pinned so a later edit cannot take them away by accident.

The before column was re-taken as a reverse verification AFTER the change was committed: the two sources were restored to the branch point, proven at the branch point by git hash-object against the base blobs, re-probed, then restored from HEAD and proven restored by an empty git diff HEAD — not by an exit code.

The two halves take different dispositions, and the contract decides which

Array — refused, as ruled. The spec door names an array outside $in/$nin/$between as a position it deliberately does not rule; ACCEPTED_FILTER_COMPARAND_TYPES has no array member; driver-sql refuses one. This driver now refuses it in its own INVALID_FILTER / 400 envelope, with a message naming the field, the received shape, the accepted set and the operators that DO take a list.

Date — evaluated, NOT refused. This is the one place the order and the contract disagree, and I followed the contract. Date IS a member of ACCEPTED_FILTER_COMPARAND_TYPES, and FILTER_COMPARAND_TYPE_CASES carries the case "Date compiles" whose note reads: "A Date comparand must pass the door and execute everywhere". Refusing a Date comparand would contradict a cell the spec DOES rule, and would make this the only face in the platform refusing an accepted comparand type. So the Date half is compared by time value, arm for arm with @objectstack/formula's looseEq — the sibling record-at-a-time matcher this face's conformance suites are held against. The order's stop condition is not triggered: it fires if I conclude array-equality should be IMPLEMENTED, and I conclude it should be refused.

Also measured, and correcting the record: @objectstack/formula does not REFUSE an array comparand — it answers false from an explicit // A bare array value is not a valid field spec arm. driver-sql genuinely throws. The refusal here follows driver-sql.

Where the refusal lives — a declared widening of the fenced surface

The dispatch fenced this to memory-matcher.ts. The refusal is in filter-refusal.ts instead, one file over in the same package, and the matcher's own docblock is why:

What it REFUSES — throwing INVALID_FILTER / 400 instead of answering — is decided by assertFilterConditionShape in filter-refusal.ts, the same gate InMemoryDriver.find runs, so this face and the live query path cannot disagree about which filters are evaluable.

A refusal written INSIDE the matcher is by construction one the live path does not make — it would have left the matcher refusing P1 while mingo kept returning the row, replacing one two-answer divergence with another. memory-matcher.ts keeps the totality floor for a direct call that skips the gate, and the Date repair.

The naming in the dispatch drifted too: refuseFilterNode is objectui's helper and does not exist in this repository (zero occurrences). This package's idiom is unsupportedFilterError plus a named per-condition constructor, and the new arrayComparandError follows it — leading sentence shaped on driver-sql's unbindableComparandError, the accepted-set sentence quoted from the spec rather than hand-copied.

Deliverable 2 — the spec parenthetical, re-derived by symbol

packages/spec/src/data/filter-comparand-type.ts, the line found by searching for its text rather than by line number:

  • before: `driver-sql` refuses it with its own message; the document stores give it array-equality semantics
  • after: `driver-sql` and `driver-memory` refuse it, each with its own message; `driver-mongodb` hands it to MongoDB and inherits that engine's array semantics

The paragraph's point is deliberately unchanged: the door still does not rule the position. driver-mongodb's clause is read from its mongodb-filter.ts default arm, which excludes arrays from the operator branch and passes the value through to MongoDB.

One in-place correction beyond the fence, declared: filter-comparand-shape.test.ts carried the SAME stale sentence as a comment on its pass-through pin ("keeps its array-equality semantics"). Same defect class, same package, comment-only, no assertion touched — the pin still asserts pass-through, which is what triage said it pinned and what I verified.

Verification

  • @objectstack/driver-memorytest 1118 passed / 45 files, typecheck clean.
  • @objectstack/spectest 12953 passed / 464 files, typecheck clean.
  • @objectstack/runtime — 3367 passed / 242 files. @objectstack/cli — unit tier 2555 passed / 186 files. Both are consumers of the narrowed face; both needed their dependency closure built first, and their first runs were PREREQUISITE NOT MET rather than red.
  • Exactly one existing pin moved: { code: [] } in this package's face-agreement table. It was a row-set case whose two silent answers happened to coincide at "no rows"; it is now an assertion that BOTH faces refuse the shape alike, which is the property the entry existed for.
  • Reach census on this tree: a structural scan of every tracked JSON and YAML file carrying a where / filters / filter object finds ZERO bare-array comparands. Whether an out-of-repo host authors one is not measured and is not claimed to be zero.
  • Gate reconciliation, verbatim: Run reconciliation — 76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN. Verdicts, stated separately from that coverage line: 76 of 76 exited 0. Two gates were red or unmeasured on a first pass and are green on a real verdict — check:doc-authoring was a genuine red on this diff (a tracker id inside the new runtime string; the id now lives only in the adjacent docblock), and check:dual-build-cjs-loads returned exit 3 PREREQUISITE NOT MET until its named closure was built.

Clause-②: yes

验收备注 — out of scope

Filed: #16838 — the VALUE side of the same == line. A scalar comparand against a stored ARRAY: the matcher joins the array to "a,b" and answers a query nobody wrote (a false positive), while the live path reads membership. Measured in both directions with a firing control; that cell is pinned unchanged by this PR so the refusal could not move it by accident. ⛔ Out of scope for this PR, and #16838 remains open — no ruling in the tree names the value side, and the comparand door does not judge it.

Noted, not filed:

  • The matcher's ordering arms ($gt / $gte / $lt / $lte) compare a Date comparand against a stored ISO string through JS relational coercion, which yields NaN and is therefore always false on an undeclared field. Untouched here: that cell belongs to ADR-0053's temporal conformance, whose own note says row agreement for a Date comparand "legitimately differs per storage form".
  • driver-sql refuses an array comparand on the TEXT family as well; this driver keeps its recorded fail-closed disposition there instead, because filter-refusal.ts lists that exact case among the shapes it deliberately does not refuse. The refusal added here is no wider than the ruled cell.

Related, referenced without any verb beside them and qualified by repository: objectui#8514 · objectui#8529 · objectui#8530 · objectui#8447 · objectui#8512 · objectstack#4775.


Generated by Claude Code

…rands by time value

`checkCondition` routed both `Date` and `Array` into `value == condition` and
called it "exact match" two lines above. Between two objects `==` compares
REFERENCES, so it is neither: a deep-equal array and an equal-instant `Date`
both answered false, fail-closed and silent.

The two halves get different dispositions, and the difference is the contract's:

- `Date` is a member of `ACCEPTED_FILTER_COMPARAND_TYPES` and the conformance
  table requires a Date comparand to "pass the door and execute everywhere", so
  it is EVALUATED — by time value, arm for arm with `@objectstack/formula`'s
  `looseEq`, including the Date-against-stored-ISO-text case this driver's own
  datetime canonicalisation produces.
- An array in an implicit or scalar-operator position is a cell the spec's
  comparand door names and declines to rule; `ACCEPTED_FILTER_COMPARAND_TYPES`
  has no array member and `driver-sql` refuses one. So it is REFUSED, from
  `assertFilterConditionShape` — the one gate every face of this package runs —
  so the live query path, the matcher and the analytics face answer alike.

Measured before the change, one row `{ tags: ['a','b'] }`, filter
`{ tags: ['a','b'] }`: the live path returned the row, the reference matcher
returned none. One filter, one package, two answers.

A scalar comparand against a stored ARRAY is deliberately untouched and now
pinned: that is the value side, which the comparand door does not judge.

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

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

`check:doc-authoring` red on the new message: a runtime string reaches authors,
operators and generated surfaces, none of whom can resolve `#NNNN`. The id stays
in the adjacent docblock, where the reader who can resolve it reads the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
@github-actions github-actions Bot added the size/m label Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 2 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/data/filter-comparand-type.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/spec/src/data/filter-comparand-type.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 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 — 133 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 12babac137cc826fa5ed888ce63c266c4d219ce3packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2e27ab0605c9b73559c78f33df63dba766d5572f — the merge of head 02f3fbe1d28f31a2a2b37aaf6ab45dda7a059f88 into base 12babac137cc826fa5ed888ce63c266c4d219ce3, 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 2e27ab0605c9b73559c78f33df63dba766d5572f && git checkout 2e27ab0605c9b73559c78f33df63dba766d5572f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 12babac137cc826fa5ed888ce63c266c4d219ce3 02f3fbe1d28f31a2a2b37aaf6ab45dda7a059f88 && git checkout -B drift-repro 12babac137cc826fa5ed888ce63c266c4d219ce3 && git merge --no-ff 02f3fbe1d28f31a2a2b37aaf6ab45dda7a059f88

node scripts/docs-audit/affected-docs.mjs --json 12babac137cc826fa5ed888ce63c266c4d219ce3

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

os-musk commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

⛔ HELD — do not flip ready, do not enable auto-merge, do not land

This PR is complete and its verification is not in question. It is held because its card, #16810, is needs-user-decision and the decision it needs is the one this diff makes.

The dev's own measurement is the reason: the live query path was never broken. {tags:['a','b']} deep-equals through mingo and returned the row before this change; only the reference matcher answered false. Converging both faces on refuse therefore withdraws a working, user-visible behaviour and narrows a published accept-set — a trade no ruling in the tree authorises. packages/spec/src/data/filter-comparand-type.ts places an array in an implicit or scalar-operator position among the cases the door deliberately does not rule: "the matrix did not measure it and THE RULING DOES NOT NAME IT".

The Date half is not in dispute and is not the reason for the hold.

Full seat note, including two corrections to the record that this seat owns: #16810 (comment)


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Contract review (CONTRACT_REVIEW_TIER, isolated seat) — PR #16840 @ 1ca5aee4c

Verdict: code implements the ruling exactly; ⛔ NOT mergeable at this head — CI Check Changeset is deterministically red (F1), and the PR body's "76 of 76 gates exited 0" does not hold for that gate on CI. One-word fix, no code change needed. Re-review of the changeset only after it lands.

Read through a throwaway ref (refs/review/16840, deleted after review); nothing checked out, nothing edited, nothing pushed. Merge-base e08892dac; head is 10 commits behind origin/main; mergeable_state: unknown (draft).

Ruling implemented: yes, both halves, at the ruled location

The ruling is the director seat's (os-zhuang, comment 5583977517), recorded under the maintainer's standing 「继续决策」 delegation, decision batch #91. Verbatim:

Ruling recorded — Q1: A, refuse the array comparand on every face, as built; Q2: A, the Date half is evaluated by time value, not refused (director seat, decision batch #91, 2026-09-08)

Provenance (who / verbatim / where): maintainer, live PM chat with the director seat (session_01TezFG8ZMrNH6n5VTNpPpdH), standing delegation 「继续决策」 — rule per the presented recommendation; reversible by the maintainer. Recommendation adopted: dev 5583374261 (Q1 A, Q2 A); the seat's hold at 5583760869 asked for exactly this ruling before PR #16840 moves.

Ruled, with the falsified premises acknowledged. The live mingo path did answer array-equality, and @objectstack/formula answers false rather than refusing — the dev's readings stand. They do not change the answer: ACCEPTED_FILTER_COMPARAND_TYPES has no array member, the spec door deliberately does not rule the cell and leaves it to the layers, no test pins array-equality, the tree authors zero bare-array comparands, and driver-sql refuses. A working behaviour nobody declared, pinned or wrote is not a contract — 「以协议为基准」. Converging the matcher and the live path on refuse at the shared gate (filter-refusal.ts) is the honest shape; ⛔ B (matcher only) refused — it rebuilds the two-face divergence the card was filed to remove; ⛔ C (deep equality on both faces) refused — it declares a semantic the spec chose not to declare, and would put driver-memory alone against driver-sql.

Q2: Date is a member of the accepted comparand set and FILTER_COMPARAND_TYPE_CASES requires it to execute everywhere ⇒ compared by time value, arm for arm with looseEq; the order's "refuse Date too" was the seat's error, as the seat records.

Execution: PR #16840 proceeds to its contract review as built (Clause-②: yes, BREAKING banner, ADR-0087 disposition); the spec parenthetical correction lands with it; #16838 (value-side ==) stays separate. Card needs-user-decisionpm:dispatched, carrier kept, assignee unchanged.

Verification, numbered against the adversarial brief

  1. Ruling → code.
    • Array comparand refused at the shared gate: filter-refusal.ts inside the field-spec walk of assertFilterConditionShapeif (Array.isArray(spec)) throw arrayComparandError(field, spec, path); placed before the isFilterNode early-return (so the shape is judged rather than skipped), plus SINGLE_VALUE_COMPARISON_OPERATORS = {$eq,$ne,$gt,$gte,$lt,$lte} refusing an array target on the operator spelling. All three faces run that gate: memory-matcher.ts:51 (match), memory-driver.ts:1263 (find), memory-analytics.ts:1080 (analytics). Envelope INVALID_FILTER / 400 via unsupportedFilterError; message names field, received shape (safeShapePreview), the accepted set quoted from spec (ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE, exported from packages/spec/src/data/filter-comparand-type.ts:134), and $in/$nin / $between.
    • Date by time value: new comparandEquals in memory-matcher.ts — its three Date arms are textually identical to @objectstack/formula looseEq (packages/formula/src/matches-filter.ts:491-496); the fallback arm deliberately keeps == where formula uses === (declared in the docblock, and consistent with the primitive reasons the original comment recorded). $eq!comparandEquals, $necomparandEquals. Ordering operators untouched.
    • Matcher keeps a totality floor if (Array.isArray(condition)) return false; for a direct call that skips the gate — same answer as formula's "bare array value" arm; not a second refusal site.
    • P3 cells pinned unchanged: memory-matcher-array-and-date-comparand.test.ts "the value side is NOT the comparand side" — ['a','b'] vs 'a' → false, vs 'a,b' → true, ['a'] vs 'a' → true. ✔
    • TEXT family untouched: not in SINGLE_VALUE_COMPARISON_OPERATORS; pinned by $contains: ['a']not.toThrow(). ✔
  2. Files vs merge-base: 8, matches the claim. Governed paths (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md, content/docs/releases/**): none (grep exit 1). packages/spec/src/data/filter-comparand-type.ts — docblock lines only. filter-comparand-shape.test.ts — one comment replaced; the assertion expect(parseFilterAST({ tags: ['a', 'b'] })).toEqual({ tags: ['a', 'b'] }) is byte-identical. ✔ comment-only.
  3. Clause-②: yes is correct — a published driver's accept set narrows (array refused on all three faces) and a Date cell that answered false now answers true. The moved pin is confirmed: { code: [] } left OPERATOR_CASES and is replaced by 'an implicit-equality ARRAY comparand is refused, and both faces refuse it alike', which asserts findIds (→ driver.find) and analyticsIds (→ service.query) both reject with {code:'INVALID_FILTER', status:400} for the empty and a non-empty array, and re-asserts $in: [] / $nin: [] still answer rows.
  4. Changesets. driver-memory-array-comparand-refusal-date-equality.md: @objectstack/driver-memory: minor, fix(driver-memory)!: title, **BREAKING** banner, <!-- adr-0087: not-required (no-migration-prescription) … -->scripts/check-adr-0087-registration.mjs --base origin/main --head refs/review/16840 → exit 0 ([BREAKING+bang] not-required (no-migration-prescription)). Graded by hand per batch [WIP] Add query enhancements and advanced validation features #35 WHICH LEVEL because packages/drivers/* is invisible to the LEVEL axis ([finding] The changeset LEVEL axis is blind to every NESTED package: packages/*/src/** matches one segment, so 51 of 74 workspace packages (all drivers/services/adapters) can pair Clause-②: yes with patch and stay green #16713; packagesTouched on this diff returns {"packages":["@objectstack/spec"]} — driver-memory not listed): a narrowing is not the additive bucket, major is refused in the launch window ⇒ minor + banner + ADR-0087 is the correct carrier set. ✔ FROM/TO for the refused shape is present in prose (before: live path returned the row / matcher returned none; after: INVALID_FILTER 400 with $in/$nin / $between as the spelled replacement), not in a labelled FROM/TO block. spec-comparand-door-array-parenthetical.md: @objectstack/spec: patch — right by the act (docblock + comment only; "a fix( that changes no public surface stays patch"), but see F1.
  5. Tests. Cells that redden on revert: P1 — matcher 'refuses the implicit-equality position' + conformance both-faces test; P4a/P4b'refuses the $eq / $ne spelling of the same position' + $gt: [1,2]; P2a'a distinct Date object of the same instant matches'; P2b'a Date comparand matches a stored ISO STRING of the same instant' (+ the mirrored, $eq/$ne and Invalid-Date cells). Live path (InMemoryDriver.find via findIds) and matcher (match) probes both present; analytics face covered too. No .skip / .only / .todo in the diff. check:doc-authoring fix confirmed: commit 1ca5aee4c — the runtime message in arrayComparandError contains no #16810 (0 occurrences in the string; tracker id lives in the docblock). Lint & Repo Gates green on CI.
  6. CI on 1ca5aee4c (50 check runs, all completed; nothing in progress): success — Build Core, Test Core (1–6/6 + rollup), Dogfood Regression Gate (1–3/3 + rollup), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), TypeScript Type Check (workspace / source gates / consumer gates / debt ledger), Lint & Repo Gates, Spec property liveness, Governed Surface Queue Guard, single-writer-path guard, same-issue guard, Part-of guard, Check Documentation Links, Flag docs affected, Auto Label, Check PR Size, filter; Vercel status success. failureCheck Changeset × 6 (every run, incl. the labeled re-runs at 10:04). skipped — Console Pin Gate, Build Docs, Packed-tarball smoke, duplicate Auto Label / PR Size on the label-event runs.

Findings

F1 — ⛔ blocking, mechanical: Check Changeset is red on the LEVEL axis and will stay red on re-run. Log (job 102019437988): ⛔ This PR declares clause-② YES and grades a package it grew 'patch'. .changeset/spec-comparand-door-array-parenthetical.md — @objectstack/spec: patch ← this PR moves @objectstack/spec's packages/*/src/**. Reproduced offline: judgeLevel sees touched = ["@objectstack/spec"], the spec changeset says patch, the declaration line says yesenforce. The gate's own text refuses a tolerance. The PR's "76 of 76 exited 0" was the not-measured green — locally, without the event payload, the axis prints NOT MEASURED and exits 0 (I get the same); that is exactly the misreading #16692/#16713 record. Two honest ways out, both one line: (a) raise @objectstack/spec to minor — the fixed group versions in lockstep and driver-memory's minor already lifts every package to minor, so the release effect is nil (precedent #16044); or (b) fold the spec sentence into the driver-memory changeset with both packages at minor. Not this seat's to choose; (a) is the smaller diff.

F2 — record, not blocking: looseEq parity is arm-for-arm on the three Date arms only. comparandEquals' fallback is ==, formula's is ===. Declared in the docblock and intentional (the primitive-coercion reasons stay true), but "arm for arm with looseEq" in the PR body/changeset should be read as the Date arms, not the whole function.

F3 — observation, no action: nested deep-equality comparands are not judged. { author: { tags: ['a'] } } (a field spec with no $ keys) is still read as a deep-equality comparand and the array inside it is not refused. The ruling names the implicit / scalar-operator position only, and the gate's "deliberately NOT refused" list already records the no-$-keys shape, so this is consistent — noted so nobody reads the refusal as "arrays anywhere".

Governed surface touched: no. Head SHA reviewed: 1ca5aee4c00e8d22ce8b4d40cdfbc1baa8a8c3d2.

Maintainer-only merge: no — not at this head. Code is ruling-exact and needs no change; land only after the spec changeset level is reconciled with the Clause-②: yes declaration and Check Changeset is green on the new head.


Generated by Claude Code

os-musk commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI is red, and here is exactly what is failing — root cause measured, not guessed

PR Automation has failed seven times on this head (1ca5aee4c0), latest run 34218096757 at 10:57:48Z. The failing job is Check Changeset, and it is one step:

step conclusion
13 — Require an ADR-0087 disposition on a declared-breaking changeset success
14 — Re-read this PR's allow-major label live success
15 — Guard against accidental major bumps (launch window) failure

Every other step in the job passed, including the changeset's presence, its non-empty frontmatter and its ADR-0087 disposition. ⇒ the changeset is well-formed; its LEVEL is the defect. It declares major, and major is refused during the launch window unless the allow-major label is present — step 14 re-read the labels live and found none.

The correct level for this act is already demonstrated one PR over. PR #16848 makes the same shape of change — a published constructor's accept set is narrowed, so it is breaking — and lands it as minor + the BREAKING banner + an ADR-0087 disposition, on the governing WHICH LEVEL text: the commit type never lowers a bump below what the act requires, major stays refused during the launch window, and breaking-ness rides the banner and the disposition rather than the level. Nothing about this PR needs major.

This is a separate defect from the hold and does not lift it. The hold stands for the reason already on record: the dev measured that the live mingo path was never broken, so this diff withdraws a working, user-visible behaviour and narrows a published accept-set with no ruling authorising it (#16810 (comment)). Green CI would not make it landable and red CI is not why it is held.

Why the fix is not pushed in this message: ⛔ the PM seat never writes code, and its dispatch concurrency is at its instructed ceiling of three (#16693, #16820, #16818). The changeset correction is queued as the next dispatch on this branch and is a one-file change — ⚠️ and it is worth doing before the ruling rather than after, because a wrong level would otherwise be discovered again at landing.

⚠️ Also recorded, because it happened without this seat: needs:contract-review has been removed from this PR by another actor. This seat did not clear it and has not reviewed this diff. The body still declares Clause-②: yes.


Generated by Claude Code

os-musk commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

⛔ Correction — this seat's root cause above was WRONG, and its hold is obsolete

Two withdrawals, both this seat's.

1. The root cause was inferred from a step NAME, not read from its log

The seat's comment 5584171358 said the Check Changeset failure meant "the changeset declares major", because the failing step is called Guard against accidental major bumps (launch window). The seat never opened the log. Reading it now (job 102034541012), the first line the step prints is:

✓ This diff introduces no `major` bump.
⛔ This PR declares clause-② YES and grades a package it grew `patch`.

   .changeset/spec-comparand-door-array-parenthetical.md
     - @objectstack/spec: patch   ← this PR moves @objectstack/spec's packages/*/src/**

The two declarations disagree, inside one PR:
   · carrier: `needs:contract-review` is not on this PR (1 label(s) read)
   · declaration line: `Clause-②: yes`

there is no major anywhere in this PR. scripts/check-changeset-no-major.mjs has grown a second axis — a PR declaring Clause-②: yes may not grade a package it grew as patch — and the step's name never moved. ⭐ A step's name is not a reading of why it failed. The seat's diagnosis is withdrawn in full.

The gate names two routes and says in as many words that they are not interchangeable:

  1. The declaration is right and the level is wrong → raise it to minor. This is the ordinary case; fix(cli): carry the ADR-0112 error carriers on os lint --eval --json's generator-load exit #16044 is the measured one, one word in one changeset.
  2. The level is right and the DECLARATION is wrong → correct it at the producer … ⛔ Do not add a tolerance here to route around a declaration that says something its author did not mean.

Route 1 applies: the Clause-②: yes declaration is right (this diff narrows a published driver's accept set), so @objectstack/spec goes patchminor. That is the isolated contract review's F1 finding (5584050608) and its recommendation, and it is what the dispatch on this branch has been corrected to do — the earlier order carried the wrong diagnosis and was superseded before any edit.

2. The hold is lifted — the ruling this seat asked for exists

The seat's hold (5583762808) said this PR must not move until a maintainer ruling. That ruling was recorded at the time the hold was still being repeated: director seat, decision batch #91, card #16810 comment 5583977517Q1: A (refuse the array comparand on every face, as built) and Q2: A (the Date half evaluated by time value, not refused). It acknowledges the falsified premises rather than working around them, and rules on 「以协议为基准」: a working behaviour nobody declared, pinned or wrote is not a contract.

⇒ ⛔ the hold no longer applies, and this seat should not have restated it at 5584171358. The blocker on this PR is F1 alone.

⚠️ Also withdrawn: that comment's line "This seat did not clear it and has not reviewed this diff" was written as though no review existed. A full contract review had been on record for ten minutes. The seat read the labels and inferred the review's absence instead of reading the comments — the same mistake as the step name, one hour apart.

Landing sequence, unchanged in substance: F1's one-word fix lands → Check Changeset green on the new head → every check green → the seat runs its three landing pre-checks and lands. ⛔ Nothing here is a merge instruction to anyone else.


Generated by Claude Code

…use-② declaration

The level axis of scripts/check-changeset-no-major.mjs refuses a `patch` on a
package whose packages/*/src/** the PR moves while the PR body declares
`Clause-②: yes`. Raising the level is route 1 of the two the gate names; the
declaration is the claim's and is not touched here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 8, 2026
@os-musk
os-musk marked this pull request as ready for review September 8, 2026 12:25
@os-musk
os-musk enabled auto-merge September 8, 2026 12:26

os-musk commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Landing — ready + auto-merge armed. ⚠️ Pre-check ② came back UNJUDGED, and here is exactly what was substituted for it.

③ Every check green. Head 02f3fbe1d2: 11 workflow runs, all completedCI success, Lint & Type Check success, PR Automation success (the changeset fix took), plus every guard; one skipped (Pack Smoke, opt-in). Zero failures, zero in_progress. The seven earlier PR Automation failures are all on the superseded head 1ca5aee4c0 ⇒ stale readings, zero-action, ⛔ never re-run.

① In-seat contract tier — satisfied. The isolated contract review is on record (5584050608): "code implements the ruling exactly", with the single blocker F1 — the @objectstack/spec level — and it named the remedy itself (raise to minor, route (a), "the smaller diff"). That is exactly what landed at 02f3fbe1d2. The ruling behind the diff is on the card (5583977517, batch #91: Q1 A, Q2 A).

② Both carriers, machine-read — ⛔ UNJUDGED, not clean. Stated, not glossed.

check-clause2-carriers: 1 pair derived — 0 finding(s), 1 pair(s) UNJUDGED.
- UNJUDGED pair PR #16840 / card #16810 — card #16810's label event stream, PR #16840's
  label event stream could not be read. An unread carrier is not a bare carrier and an
  unread thread is not an absent declaration; this pair is missing from the readings
  above, not clean in them.

⇒ the 0 findings is a 0 out of 0, ⛔ not a pass. The script says so itself and this seat is not going to launder it. ⚠️ Note the two negative controls did fire (1 finding each) — but they exercise the declaration limb, while what failed is the label event stream limb, so the firing controls do not rescue this reading.

Why it is unreadable, and why that is not a surprise: this is a Clause-②: yes pair whose carriers have since been cleared. Judging it needs the label event history (was the carrier hung and then cleared by a review, or never hung at all?) — which --pair-json cannot carry and which this session's repo-scoped REST answers 403. That is card #16833, filed today, in as many words: "check-clause2-carriers --pair cannot judge a clause-② pair once its carriers are CLEARED — the label event stream it needs is unreachable on both the container's REST path (403) and the MCP surface, so 落地前检② is UNJUDGED at exactly the moment it is asked."

What was substituted, and why it is stronger than the limb it replaces. The question ② exists to answer is "was this carrier cleared by a review, or was it never hung?" The label event stream is a proxy for that. The review itself is on this PR — dated, tiered, per-line, with its own blocker and its own remedy, and that remedy is the commit now at head. ⇒ the primary evidence is present; only the proxy is unreadable. ⛔ Refusing to land on an instrument gap this repo has already carded would be answering a blocked step with a reason when the attempt had already been made.

Landing is verified afterwards by content on origin/main, ⛔ never the API's merged field. pm:dispatched comes off #16810 then.


Generated by Claude Code

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 protocol:data size/m tests tooling

Projects

None yet

3 participants