Skip to content

fix(formula,plugin-security): the cross-class field-comparison refusal leads with its remedy, so REST callers read the fix (#20869) - #20972

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20869-cross-class-remedy-first
Oct 1, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20869-cross-class-remedy-first

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20869
Clause-②: no

What this changes

The refusal of a field-to-field comparison across comparison classes (INVALID_FILTER / 400) now opens with its remedy, on both of its producers. The REST door bounds a 4xx message by cutting its tail (CLIENT_MESSAGE_MAX = 500: 499 characters plus an ellipsis). Both producers wrote the remedy last, so no caller ever read it. The bound is not touched; that is #5423's decision. The producers change.

  • packages/formula/src/matches-filter.ts, crossFieldClassError. The remedy sentence comes first, byte-identical to the one the message ended with. The diagnosis is shortened so the whole message is 494 characters and reaches the wire whole. Order: the remedy; what is refused (two columns with no shared class, and the classes); why it is refused; why the columns are withheld. It still names no column, operator or policy; the columns travel on the error's symbol key for the server log only.
  • packages/plugins/plugin-security/src/explain-engine.ts, crossFieldRefusalForExplain. The remedy sentence, also byte-identical, moves before the subject and the diagnostic. Those have no length bound: object, field and policy names declare no maximum (SnakeCaseIdentifierSchema, the object name and the RLS policy name are regex-only), and the subject lists every refused policy. So no subject-first order keeps a trailing remedy for every policy; at index 0 it survives any length. The reason drops one redundant clause ("instead of judging a record": the same sentence already says explain "reports no verdict").

Code, status and trigger are unchanged. Only the text moves and shortens.

Measured through the real handlers

ObjectQL on driver-sql (better-sqlite3), SecurityPlugin, RestServer route handlers, and a policy record.status != record.amount. "Remedy at" is the index where the remedy sentence starts.

door producer before (013f97df93) after
POST /data/:object (insert: the RLS write check) record matcher thrown 972, remedy at 825; wire 500 with ellipsis, remedy absent thrown 494 = wire 494, no ellipsis, remedy at 0
GET /data/:object (find) driver-sql's read refusal 383, whole, no ellipsis unchanged
POST /security/explain, short names explain engine thrown 601, remedy at 477; wire 500, remedy absent thrown 573, remedy at 0; wire 500, cut in the reason
POST /security/explain, 60-character names explain engine thrown 920, remedy at 796; wire 500, remedy absent thrown 892, remedy at 0; wire 500
control: GET /data/:object with { title: { $bogus: 1 } } driver-sql, another class 228, wire equals thrown unchanged

A find never carries the matcher's message. Only the RLS write check (security-plugin.ts, satisfiesCheck) and explain (matchUnderDeclaredColumns) hand matchesFilterCondition the declared columns its class rule reads; the other runtime caller (objectql having-filter.ts) passes none. On /data a find answers driver-sql's own read refusal of the same comparison, 383 characters, which already reached the wire whole and states the rule ("compared as the same type class"). The matcher's text reaches /data on an insert or an update. So the /data pin covers both: the insert carries the matcher's remedy, and the find carries its read refusal whole.

Pins

  • packages/rest/src/cross-class-refusal-remedy-on-the-wire.test.ts (new, the real stack, both envelopes this family speaks):
    • /data insert: the wire message starts with the matcher's remedy, is under the bound, equals the thrown message, and names neither column;
    • /data find: the read refusal reaches the wire whole and states the same-class rule;
    • POST /security/explain: the wire message starts with the explain remedy, is within the bound, and names the policy;
    • long names (object, policy and fields about 120 characters each): explain is cut at exactly 500 with the remedy intact, and the matcher's message is identical to the short fixture's;
    • control: a short refusal of another class (unsupported operator, 228 characters) reaches the wire equal to the thrown message.
  • packages/formula/src/matches-filter-cross-field-class.test.ts: the message starts with the remedy, is under 500, is the same for long column names, and keeps the clause order.
  • packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts: every refused cell (5 predicates, read / update / delete, SQLite and sqlite-wasm) asserts the message starts with the remedy.
  • packages/plugins/plugin-security/src/rls-check-cross-class-field-refused.test.ts: the pinned opening moves with the text.

Red, then green. The new REST pin reads the producers through their dist/ (the rest to plugin-security pair is unaliased and registered in check:test-source-alias). At 2cfaa6522f, against producer builds from BASE 013f97df93 (new-text markers: 0 in both dists): 3 failed (insert, explain, long names) and 2 passed (find, control). After rebuilding both producers from 2cfaa6522f (markers: 1 in each): 5 passed.

Verification (head dc440bff6b, after merging origin/main at f6ccca4a44)

  • Package suites at 5fde18e296 (before the merge):
    • @objectstack/formula: test 42 files, 1241 passed; typecheck OK, test-layer debt held;
    • @objectstack/plugin-security: 149 files, 3227 passed, 23 skipped (the PostgreSQL legs, no server); typecheck OK;
    • @objectstack/rest: --project local 245 files, 4875 passed, 114 skipped; typecheck OK, 0 test-layer errors.
  • At dc440bff6b (the merge brought commits into rest): the formula pin file 23 passed; the three plugin-security cross-class files 150 passed, 23 skipped; @objectstack/rest --project local 246 files, 4890 passed, 114 skipped.
  • Gates. node scripts/pm/dispatch-gates.mjs --commands at dc440bff6b derives 65 commands; all 65 exit 0. --ran reconciliation: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN". Three of them (check:dual-build-cjs-loads, check:i18n, check:type-check-debt) first exited 3 (PREREQUISITE NOT MET: nothing measured). After the full turbo run build --filter='./packages/*' --filter='./packages/*/*' (71 tasks), all three exit 0.
  • Lint, narrowed, at dc440bff6b. ESLint's own isPathIgnored and calculateConfigForFile put 6 of the 7 changed paths in its population; its config ignores the changeset .md. lintFiles over those 6 with allowInlineConfig: false (the lint script's --no-inline-config), JSON formatter: 6 files, 0 errors, 0 warnings. The config enables no type-aware linting (no parserOptions.project, no typed rules), so this diff cannot move a verdict on an untouched file. The repository-wide pnpm lint belongs to CI.

Acceptance notes

  • driver-mongodb, a sibling refusal of another class (fieldReferenceUnsupportedError: the driver has no field-to-field lowering at all). It is 538 characters from source, so the bound cuts it. Its remedy ("Compare against a literal value instead.") ends at 410 and survives; the cut drops the end of its withholding sentence. Not edited here. Source reading plus the bound's arithmetic; not measured through a REST door, because no MongoDB server was available.
  • packages/rest/src/security-explain-envelope.test.ts still builds its matcher and explain refusals by hand, with the old opening, and says neither @objectstack/formula nor @objectstack/plugin-security is a dependency of rest. plugin-security is a devDependency now. The hand-built text is a fixture, not a pin of either producer, and the route reads only its code and status. Left as is.
  • The explain copy never carried the class list or a withholding sentence (it names the policy and both columns by design, since the explain report publishes the same predicate), so it keeps remedy, subject and diagnostic, and reason.

Generated by Claude Code

…remedy

The matcher's refusal now opens with the fix and fits the REST client
message bound whole (494 characters); the explain engine's copy puts the
same remedy before its unbounded subject and diagnostic.

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
…edy on the wire

The /data insert and find doors and POST /security/explain, through the real
security layer on driver-sql: each wire message carries its producer's
remedy, a long-names fixture keeps the remedy under the bound, and a short
refusal of another class reaches the wire unchanged.

Claude-Session: https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 30, 2026
@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, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 22 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 4957ee5ef0e660fc9ee4525d83f13aa32ef8e969 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 4957ee5ef0e660fc9ee4525d83f13aa32ef8e969

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: dc440bff6bf9c731ce7f315ac8015de9624370b1
Local-runs: none

Inputs, and nothing else: card #20869 (body; comments 5912895835, 5920895653, 5921682938), #20355 with its comments (the withheld posture), #5423, PR #20972 (body, file list, net diff against main at the head: 7 files, +349/-16, equal to the API file list), and the head's check-runs at the moment of reading. Read-only: git reads of refs/review/20972 and origin/main, string arithmetic on the literals in the diff; nothing built, run or re-run.

① Derived judgments

  1. Accept set unchanged — right. crossFieldClassError still sets INVALID_FILTER / 400 and is thrown from the same site (matchesFilterCondition, only when options.fields is supplied, after findCrossFieldClassRefusal); the verdict function and class table in @objectstack/spec are untouched; crossFieldRefusalForExplain still copies code, status and cause from the matcher's error and is raised from the same matchUnderDeclaredColumns catch. No package.json, export or route moves. Outside the two producers the diff is tests and one changeset.
  2. Matcher text — right. Old literal 972 characters, remedy at 825 and last; new literal 494, remedy at 0; the remedy sentence is byte-identical (147 characters). Clause order remedy, "share no class" (186), "so it is refused" (343), "withheld" (411), as the formula pin asserts. Names no column, operator or policy: the text is fixed with no interpolation; the columns and operator ride the non-enumerable symbol key (crossFieldClassRefusalCarriedBy) into the write gate's server WARN, the finding: after the #7598 Q1=B ruling, a read scope with a driver-refused field reference answers 400 from the driver — which cuts across #5367's attribution argument on that one path #7929 withheld posture RLS enforcement: the write check (packages/formula matches-filter) admits a cross-class field-to-field comparison that driver-sql's read refuses — one classification, one answer per policy (the engine half of #20347) #20355 adopted.
  3. Every matcher clause true against the code. "compare a field only with a field of the same class, or fix the declaration": crossFieldComparisonVerdict answers comparable only when both columns hold the same class. "(number, text, boolean, date, datetime, time; file, formula, list and object fields have none)": CROSS_FIELD_COMPARISON_CLASSES is exactly numeric, text, boolean, date, datetime, time ("number" for the numeric class, as the old text also wrote it), and CROSS_FIELD_NO_CLASS_REASONS is exactly list-or-object, file, formula (multiple: true on a multi-capable type lands in list-or-object, covered by "list"); complete and correct, and "share no class" covers both the cross-class and the no-class verdicts the error fires on. "SQL and this evaluator answer it differently": the spec module header records the measurement (SQLite orders by storage class where JS coerces; boolean 0/1 against true/false; three temporal text shapes), and the old text carried the same claim. "as on the read path": driver-sql's crossFieldComparisonClass delegates to crossFieldColumnVerdict and refuses the read with uncompilableFieldReferenceError, INVALID_FILTER / 400. "withheld, as the caller may not have written the policy; the server log names them": the write gate's WARN names the policy, field, operator and reference.
  4. Explain text — right. Remedy byte-identical (124 characters), now at index 0 before the subject; the dropped clause is exactly "instead of judging a record", redundant with "reports no verdict" in the same sentence. The remaining clauses hold: the subject names every refused policy (refusedPolicyNamesOf over policyMembersOf) or "A row-level filter on 'object'" when none attributes; the diagnostic names both columns and their classes; "the find answers INVALID_FILTER / 400" is RLS enforcement: the write check (packages/formula matches-filter) admits a cross-class field-to-field comparison that driver-sql's read refuses — one classification, one answer per policy (the engine half of #20347) #20355's landed behaviour. Names declare no maximum: SnakeCaseIdentifierSchema carries no .max() by design (its header says so); object name, field name and RLS policy name are regex-only. So no subject-first order could bound the remedy, and index 0 is the only order that does. The old explain text crossed the bound for every name length, not only the measured ones: its fixed text alone is 335 characters and the shortest reconstructable message (one-character names, no attributed policy, a one-sided no-class diagnostic) is 544, with a 124-character remedy starting past 420. "Always cut" is therefore true on both producers.
  5. Door correction — right against the code paths. At the head matchesFilterCondition has four runtime callers; only the write check in security-plugin.ts (checkFieldOptions from writeCheckFieldOptions) and matchUnderDeclaredColumns in explain-engine.ts pass fields; objectql/having-filter.ts and the tenant-wall judgement in security-plugin.ts pass none, and the class rule runs only under options?.fields. A /data find's row filter is compiled by driver-sql, whose read refusal is a fixed 383-character text carrying "compared as the same type class", under the bound. Triage's pin wording "through /data find" named a door that never carries the matcher's text; the dev's pin, insert for the matcher's remedy and find for the read refusal whole, is the right correction. One precision, not a defect of the diff: the update path is conditional. A by-id update reads the caller's pre-image under the caller's own context (getCallerPreImage, then readRowById with context) before satisfiesCheck, so a cross-class predicate that is also in the caller's read filter (a using, or operation: all as in the fixture) answers driver-sql's read refusal on update, and the matcher's text reaches /data on an update only when the refused comparison is the check. The changeset's "on an insert or update through /data" and the PR body's "reaches /data on an insert or an update" describe the producer's reach, not every policy; the pin's insert leg is the unconditional door. A second precision in the PR body: "the other runtime caller (objectql having-filter.ts) passes none" — there are two such callers, the tenant-wall check in security-plugin.ts being the other; the conclusion stands.
  6. New REST pin — catches a regression of either producer. Insert leg: starts with the matcher's remedy, under 500, equal to the thrown message, names neither column; a remedy moved back or a longer text fails it. Explain leg: starts with the explain remedy and a space; a reorder fails it. Long-names leg: exactly 500 ending in the ellipsis with the remedy intact, and the matcher's message identical to the short fixture's (fixed text). Control: a short refusal of another class reaches the wire equal to the thrown message. The red leg the dev reports (3 red against the producers' base dist, 5 green after the rebuild) is the shape that proves the pin bites; read-only here, not re-run. The pin reads the producers through dist: the @objectstack/rest row in check-test-source-alias.mjs already lists plugin-security, driver-sql, objectql, metadata-protocol and spec as unaliased; turbo's test depends on ^build; the file is in the local project's population (default include; absent from vitest.repo-tests.json). @objectstack/plugin-security as a rest devDependency is already present on main (it entered at f7e562457e, fix(metadata-protocol): key the #3050 authoring gate on authoringChannel so ADR-0090 D11 OWD rules run on host-config deployments #7710); this PR adds no dependency edge, and its file list holds no package.json.
  7. Existing pins moved, not weakened. The formula pin adds the long-name and clause-order case; expectExplainRefuses now also asserts the opening across 5 predicates, read/update/delete, better-sqlite3 and sqlite-wasm, as the PR body says; the rls-check regex moves to the new opening. No other file at the head carries the old opening except the hand-built shape fixture in security-explain-envelope.test.ts (③, finding 2); no i18n bundle, docs page or D3 entry quotes either text (the D3 entry describes the withholding only).
  8. Review faces, sentence by sentence. Changeset: the bound (500; cut to 499 plus an ellipsis, truncateClientMessage) true; "both put the remedy last, so the remedy was always cut off" true (item 4); 972 and 825 true; "494 and reaches the wire whole" true; "the remedy sits in the first 125 characters" true (124); "with long names the REST door may cut the reason at the end" true but understated — with the long fixture the cut at index 499 lands inside the diagnostic (which starts at 412), so that wire copy names neither column and carries no reason, and the pin correctly asserts only the remedy for that case; "Unchanged: code, status, which comparisons are refused, the find's refusal (383, whole), every other refusal" true; "601 characters with a short policy name" is the dev's before-reading — the literals plus the pin's own SHORT fixture compute to 608 before and 580 after, so that row was measured on names 7 characters shorter than the pin's fixture; not pinned, not load-bearing, and the 60-character row (920, 796, 892) reproduces exactly from the literals. PR body: "Code, status and trigger are unchanged. Only the text moves and shortens" true; "GET / POST explain" both mounted; the class list, name-bound and subject claims true (items 3 and 4); the measurement table as above; the verification, gate and lint paragraphs are the dev's own local runs, not re-run here — the head's check-runs are the verdicts (item 9).
  9. Check-runs on the head, read at 2026-09-30T23:55Z: 31 runs; 15 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke), 13 in_progress, 0 failure. Successes include Build Core, Check Changeset, Governed Surface Queue Guard, Type Check debt ledger and source gates, and the claim, branch and size guards. Still in progress: Lint & Repo Gates, Test Core (1 to 6), Dogfood Regression Gate (1 to 3), Temporal Conformance, Type Check workspace and consumer gates. The gate-carrying set is not yet green (in_progress is not a pass), and nothing has concluded against the head. Not waited on, not polled.

② Semver level

  • @objectstack/formula patch, @objectstack/plugin-security patch — right. Both are released packages and a bug fix takes patch, never none and never skip-changeset. The diff publishes one refusal message's text per producer, reordered and shortened: no code, status, trigger, accept set, export or authorable key moves, and refusal prose is not a surface any consumer pins outside the moved test (grep at the head). @objectstack/rest gains a test only and publishes nothing, so it is correctly absent from the changeset.
  • Clause-②: no — right, present in both the PR body and the changeset body with no widening or narrowing arm: which comparisons are refused is exactly what it was. The changeset carries the migration-free shape a patch needs and states what is unchanged.

③ Boundary flags

  • Assignee unset. The executor's label-write --assign was refused by its local permission layer and not re-routed. Not a diff property; the PR's assignee should mirror the card's (os-support-ai). Escalated to the seat to set before landing; no verdict weight.
  • origin/main merged once. Confirmed: one merge commit at the head (dc440bff6b, parents 5fde18e296 and f6ccca4a44); the three non-merge commits carry the model-free Claude-Session and Co-authored-by: Claude pair; the net diff is exactly the seven listed files. Since that merge main moved 8 commits (to 83480c6a2f); one (fix(security,service-analytics)!: the security contract publishes which fields a caller may query on, and the analytics field gate refuses a masked field as a group or filter member (#20935) #20955) edits security-plugin.ts without touching the write-check seam (matchesFilterCondition and writeCheckFieldOptions untouched). Textually disjoint; the queue's rebuilt generation validates the joint state.
  • Out-of-scope finding 1, driver-mongodb fieldReferenceUnsupportedError, carrier none. Verified from source: 538 characters, the wrapper adds nothing, the remedy "Compare against a literal value instead." spans 370 to 410 and survives the cut; what the cut at 499 drops is the tail of the withholding sentence ("…because the filter may be an" is kept, "access policy the caller did not write." is lost). Not this card's defect class, since the prescription reaches the wire; Prime Directive chore: version packages #10 allows an acceptance note for it. Escalated as a seat option only: a low-priority card if the seat wants every over-bound 4xx text catalogued as rest-server 的 4xx 直通把 ≥500 字符的 message 整条换成 "Request failed" —— #5368 刚写好的过滤器拒收措辞,客户端一个字也收不到(实测) #5423 once did. Nothing in this PR depends on it.
  • Out-of-scope finding 2, stale hand-built fixture in security-explain-envelope.test.ts, carrier none. Verified: line 378 carries the old opening as a short shape-only string, and the header says neither producer package is a dependency; plugin-security has been a rest devDependency since fix(metadata-protocol): key the #3050 authoring gate on authoringChannel so ADR-0090 D11 OWD rules run on host-config deployments #7710, not since this PR, while the door itself imports neither. The test pins the route's envelope (code, status, skeleton), not a producer's text, so nothing is masked. Acceptance note adequate; a one-line rewrite belongs to the next rest-lane PR touching that file.
  • No ablation beyond the red leg. The red leg is the ablation this pin needs (the same test red against the producers' pre-change dist, green after) and the dev reports it measured, with counts. Accepted; read-only here.
  • Other declared deviations. The pin-wording correction (find, to insert plus find) is judged right in ① 5; the explain reason trim in ① 4; measurement scripts run from the scratchpad left nothing in the tree (the diff shows none). open_questions: none declared, none found.

Implemented-by: claude/issue-20869-cross-class-remedy-first
Reviewed-by: session_01DEvba2nBuD4tWzfq8r8NFY

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 23:57
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 05be352 Oct 1, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20869-cross-class-remedy-first branch October 1, 2026 00:16
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

1 participant