Skip to content

fix(lint)!: refuse a sharing-rule condition that compares a field with a json or multiple field when it is authored - #20375

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-19886-stage-2g-sharing-arm
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-19886-stage-2g-sharing-arm

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #19886
Clause-②: no

Stage 2g of #19886: the sharing-rule twin of stage 2f, and the only remainder item of release 5861981062. Seat answer A in the 2f ACCEPT (5861343773): "Stage 2g (the validate-sharing-rule-enforceability.ts arm) measures the sharing runtime (rule-criteria.ts → engine.find → driver-sql) FIRST. It reuses this PR's classification". Claim 5862004488. Base 67047171, head 79a2ad1e. The changeset declares Clause-②: no (narrowing), BREAKING, @objectstack/lint minor, following the 2d / 2e / 2f precedent.

What changes

validateSharingRuleEnforceability (packages/lint) gets one arm. When a sharing rule's condition lowers, the arm reads the lowered filter against the declared field map of the rule's anchor object. It reports sharing-rule-unlowerable-condition for every field-to-field comparison (==, != and the four ordering operators, either side, under ! too) in which either column is DECLARED to hold a list or an object.

  • One classification, not a copy. The arm calls listHoldingComparisons from validate-rls-predicate-enforceability.ts, the 2f helper. That helper reads the spec's STRUCTURED_JSON_TYPES and isMultiValueField, the two sets driver-sql refuses such a comparison by. The share is the smallest one that works: export on listHoldingComparisons and on its ListHoldingComparison result type, plus one comment sentence. No RLS line changes behaviour, and the 2f pins run unchanged (see Tests).
  • The anchor only, lazily. The graph is indexed per anchor, once, and only for a condition that lowered. A lowered sharing criterion can only address its anchor's own columns, because a cross-object path does not lower. Indexing the whole stack up front changed which refusal an unreadable reference carrier raises and turned the existing #18550 pin red in the first run. The lazy anchor-scoped graph keeps that pin byte-identical.
  • The id. It keeps sharing-rule-unlowerable-condition. The fix is the same rewrite of the condition, and the same class's literal spelling (record.status == ['a', 'b']) is already reported under that id, refused by the compiler rather than by the driver. The id's one-line doc now names both halves. A separate id was weighed and not taken (see Decisions).
  • Doors. os validate / os build / os lint run this rule. The metadata save door for a sharing_rule does not run it, as before (see Acceptance notes). No gate is added and no runtime seam changes.

Files:

  • the rule (+105 / −8);
  • the RLS rule (+5 / −2: two export keywords, one comment sentence);
  • a new table-driven pin beside the existing sharing tests;
  • the changeset.

The cli admission parity test (rls-policy-authoring-admission.test.ts) has no sharing-rule family, because the sharing rule never crosses the save door, so it is untouched.

The refusal text (new)

Message, from the real os validate at head:

Sharing-rule condition record.status != record.tags lowers, but compares a field with a field that holds a list or an object: record.status != record.tags, where tags is declared type: 'json'. A column that holds a list or an object is not one comparable value, on either side of a field-to-field comparison, so the platform refuses the comparison instead of evaluating it: the rule is seeded into sys_sharing_rule, but every criteria query it runs is refused on the SQL drivers (INVALID_FILTER / 400: driver-sql refuses a cross-field comparison against such a column by its declared type), and SharingRuleService reads a refused query as matching no record. No sys_record_share grant is ever materialised, at boot or on any later write, and the only signal is a WARN line in the server log. The rule is declared and grants nothing.

Hint:

A field compared with a json or multiple field has no row-filter form: a row filter compares one value with one value, and cannot test membership in a list another column holds. Compare with a single-valued column, or with a literal — "one of these values" is record.status in ['open', 'pending'] — or keep the value the rule keys on in a single-valued field and compare with that.

The class sentence is the 2f RLS arm's sentence, word for word; a pin holds the two equal. The hint drops the RLS hint's current_user alternative, because a sharing condition that reads current_user is refused (sharing-rule-runtime-variable-condition).

Zone 1 P1, measured FIRST (before any edit, at 67047171)

A probe drove the real SharingServicePlugin over a real ObjectQL:

  • init, then start, then the kernel:ready handlers: seeding through bootstrapDeclaredSharingRules from registerApp's sharingRules collection, and hook binding;
  • then the kernel:bootstrapped backfill;
  • then a post-boot insert and update, to exercise the per-record hook path.

Setup:

  • 10 leak rules and 4 controls, each sharing READ with its own user, on a private object;
  • 3 seeded rows, including one whose status is a member of tags, so a rule that "should match" exists;
  • each recipient's read went through the sharing middleware.
driver leak rules (10: json list and object, multiple lookup, multiselect, multiple user; ==, !=, !(==), >, less-or-equal; both orders) controls (text != / == text, literal, json != null)
driver-sql (better-sqlite3) all 10 seeded with criteria_json; every criteria query INVALID_FILTER / 400; one WARN [sharing-rule] criteria query failed per rule at the backfill; 0 grants; the recipient reads 0 rows; after the insert and update, still 0 grants, with only engine-level Find operation failed warns that name no rule granted and enforced exactly the matching rows (for example text != text: r1, r2, then r4 after the insert)
driver-sqlite-wasm identical, cell for cell identical
driver-memory (test/demo; #15104, frozen) reads a { $field } comparand as a literal: != grants every row and == / ordering grant nothing equally wrong (text == text grants nothing) — this is #15104, not the list class
  • driver-mongodb: not live-measured. Its source (mongodb-filter.ts) refuses every { $field } comparand, scalar or list.
  • driver-turso: local and replica mode extend SqlDriver. The remote transport was not measured.

Verdict: P1 holds. On the SQL drivers the runtime refuses the comparison, and the sharing path turns the refusal into a silent zero-share: the rule is declared, seeded, and grants nothing. The prohibition does not apply.

Zone 2, measured

1. The authoring doors.

  • The cells: 85 sharing rules in one stack.
    • 72 leak cells: 7 operators × 5 list/object columns (json, address, multiple lookup, multiselect, multiple user) × 2 orders, plus a list-against-list cell and a compound one;
    • 10 scalar controls;
    • 3 firing controls.
  • The doors: the real CLI (node packages/cli/bin/run.js validate --json) and the save door (saveMetaItem({ type: 'sharing_rule' }) over a real ObjectQL, sqlite-wasm).
tree leak (72) controls (10) firing (3) save door
67047171 (base) 72 clean 10 clean 3 reported (list literal and function call → unlowerable; current_user → runtime-variable) 85 / 85 accepted, firing controls included
79a2ad1e (head) 72 refused, one finding each, one consequence sentence and one hint across all 72 10 clean 3 reported, rule and message identical to base 85 / 85 accepted (unchanged; this rule is CLI-only)

2. The runtime. P1 above. The door that answers is the driver, through engine.find: driver-sql's crossFieldComparisonClass, by declared type. plugin-sharing catches the refusal (findMatchingRows / matchRecord) and reads it as "no match".

3. Where the arm lives. compileCelToFilter(condition, { variables: {} }), the seeder's exact call, returns the same lowered FilterCondition shape the RLS rule walks. The probe's seeded criteria_json shows it: {"status":{"$ne":{"$field":"tags"}}} and {"$not":{"status":{"$eq":{"$field":"tags"}}}}. So the smallest arm is one call to the 2f helper on result.filter when result.ok.

4. Census (narrowing).

  • objectstack at 67047171, packages/** + examples/**, non-test: 3 declared sharing-rule conditions (app-showcase ×2, a qa/downstream-contract fixture), all field against literal. 0 field-to-field, 0 against a json / multiple column.
  • cloud main 96eb092 (the ls-remote tip): 0 declared sharing-rule conditions.
  • The real os validate at head over app-crm, app-multi-package, app-showcase and app-todo: 0 sharing-rule-* findings. Error and warning counts are unchanged from base for the three apps that ran there. app-showcase could not load at base before its dependency closure was built, and at head it is valid with 0 errors.

Tests

All at head 79a2ad1e, in one run through os-verify-lock.sh (VERDICT command-exit 0, held 603s on a shared box):

  • @objectstack/lint build exit 0 (DTS emitted, 4/4 declaration files).
  • @objectstack/lint, the targeted five files (the new pin, the existing sharing tests, the 2f RLS list-holding pin, the rule-id barrel and the wiring guard): 5 files / 480 tests pass.
  • @objectstack/lint, full package: 113 files / 4704 tests pass. The existing sharing-rule pins and the 2f RLS pins run unchanged, including the #18550 unreadable-carrier pin that the first draft of this arm turned red.
  • @objectstack/lint typecheck: exit 0 (source and test layer).
  • @objectstack/cli unit project: 230 files / 3296 pass. integration project, the admission parity pin: 30 / 30 (named by the dispatch as a consumer suite; not touched).
  • @objectstack/plugin-sharing, full package (read-only consumer; no pin flips): 37 files / 913 pass.

The new pin has 64 tests:

  • 28 cells: 7 operators × 2 classes × 2 orders, each asserting the envelope (severity, rule id, path, where) and the whole message verbatim, plus the hint's first clause;
  • 16 declared classes read from the spec;
  • 11 controls;
  • the parsed tier, the compound and two-comparison cases, an inactive rule, the RLS class-sentence parity, the not-judged cases, one-defect-one-finding against an unlowerable condition, the anchor arm reported beside it, and the os validate rule table.

The first run was at 53275286, before the lazy-graph fix: lint full 1 failure out of 4704, the #18550 pin; cli unit 230 / 3296; plugin-sharing 37 / 913. That failure is why the graph is anchor-scoped and lazy.

Ablation

From committed 79a2ad1e, run by ablation.sh with an EXIT INT TERM trap:

  • Mutation. scripts/ablation-replace.mjs replaced if (listHolding) findings.push(listHolding); with a guard that never pushes, carrying the marker ABLATION_19886G. Anchor 1 to 0, marker 0 to 1, blob a95f00af to f507e6a3.
  • Build. pnpm --filter @objectstack/lint build exit 0. ablation-dist-preflight.mjs @objectstack/lint found the marker in all 4 built entries.
  • The new pin plus the existing sharing tests: 50 red / 64 green. Red: every refusal cell, class and property. Green: the 14 controls and not-judged cases, and all 50 pre-existing sharing tests.
  • The real os validate over the 85-cell stack, with the ablated dist: the 72 leak cells are clean again, the 10 controls clean, and the 3 firing controls unchanged. The arm alone carries the refusal, through the built CLI.
  • Restore. git checkout HEAD -- ABS_PATH: blob equal to HEAD (a95f00af), git diff HEAD empty. Rebuilt with exit 0; the preflight --absent reads the marker absent from all 14 built files and the tree clean.

Gates

Re-derived at head with dispatch-gates.mjs --commands --repo objectstack-ai/objectstack. The change set is these 4 paths against merge base 67047171, and it is the same 60 commands as at 53275286. Every one was run at 79a2ad1e, with exit codes recorded to disk before they were read. The --ran reconciliation reads 60 derived, 59 run, 1 NOT MEASURED, 0 UNRUN:

  • 54 light gates: exit 0.

  • 5 build-reading gates exit 0: check:dts-closure, check:lean-entry-closure, check:published-files, check:sourcemap-no-sources-content and check:type-check-debt.

  • NOT MEASURED: check:dual-build-cjs-loads, reason: exit 3, PREREQUISITE NOT MET. Six packages outside the built closures have no dist/ (studio, client-react, embedder-openai, knowledge-ragflow, organizations, service-cluster-redis), and the gate needs a whole-tree pnpm build. As a proxy, lint's dist/index.cjs and dist/runtime.cjs both require() cleanly.

  • ADR-0087: not-required (already-registered cel-predicate-one-value-comparand-refused).

    • The entry's surface names sharingRules[].condition and this exact class: "a field compared with another field (==, !=, or an ordering operator) where either column holds a list or an object on the record, as a json column or a multiple lookup does".
    • Its replacement carries the prescription.
    • check-adr-0087-registration exits 0 and reads the marker as [BREAKING+clause-②-narrowing] not-required (already-registered).

Decisions settled in this PR (open to the reviewer)

The id: reuse sharing-rule-unlowerable-condition (chosen) or add sharing-rule-unenforceable-condition.

  • Actual business need: 0 producers write this shape (census), so neither choice moves an author.
  • Long-term soundness: this file assigns ids by fix, and the fix here is the unlowerable one, a rewrite of the condition. The class's literal spelling already sits under that id. The cost is a constant whose name says "unlowerable" for a condition that lowers, so its doc line now names both halves. 2f made the same trade in RLS (rls-predicate-unenforceable).
  • Preventing AI authoring mistakes: the message states "lowers, but compares…" and the real consequence, so a reader of --json is not sent to "rewrite into the pushdown subset".
  • Startup-stage scope: a new id is a new permanent public constant and barrel line, for no author.

Acceptance notes

What remains on #19886

The release's remainder list (5861981062) held stage 2g only, and this PR closes it, hence Fixes. Carried elsewhere: #20347 (cross-class comparisons, a different family) and #15104 (frozen, driver-memory).


Generated by Claude Code

…h a json or multiple field

Stage 2g of #19886 (the sharing-rule twin of stage 2f). The condition lowers,
so the seeder seeds the rule, and driver-sql then refuses every criteria query
it runs by declared type; SharingRuleService reads the refusal as matching no
record, so the rule grants nothing. validateSharingRuleEnforceability now
reports it, through the RLS rule's listHoldingComparisons (exported, not
copied).

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

Clause-2: no (narrowing), BREAKING; ADR-0087 not-required (already-registered
cel-predicate-one-value-comparand-refused).

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

Building the whole stack's object graph up front threw on an unreadable
reference carrier before the anchor arm could refuse it with its own
label, flipping the #18550 pin. The arm now indexes the rule's anchor
lazily, once, and only for a condition that lowered: the only object a
lowered sharing criterion can address.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/validating-metadata.mdx (via validateSharingRuleEnforceability (symbol, a top-level function))
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 — 4 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 5a6267f4862556fd7422cd161cd931b03aa17b72 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5a6267f4862556fd7422cd161cd931b03aa17b72

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 79a2ad1e5146fb987c280fa585bd93b7b963580a
Local-runs: probe — the dispatch ordered a measured review on this p1 security card, so one detached worktree was opened at the head under the reviewer's scratchpad, pnpm install --frozen-lockfile run there, the cli closure and the four example apps' dependency closures built through the verify lock (a shared-turbo-cache hit, 62/62 tasks; the lint dist was confirmed to carry the head arm and no ablation marker before use), and through the lock in one further hold: the seven lint pin files at the head, a merge-base control (the two lint sources checked out at 67047171, blobs 9c7fd987 and 2c4be428, lint rebuilt; the merge-base differs from the head in exactly those two sources, the new test file and the changeset), the pre-existing pins in both states with vitest's JSON reporter, a 232-cell table through the real os validate in the base, head and ablated states, the real os validate over the four example apps in both states, an unreadable-carrier probe in both states, and one ablation with its restore; outside the lock, three runtime probes through the real SharingServicePlugin (driver-sql, driver-sqlite-wasm, driver-memory) and one schema-door probe; the worktree and the bare probe clone were removed afterwards

Read: the PR body, the diff against the merge-base 67047171 (4 files, +386/−10, 3 commits 7364f0e9, 53275286, 79a2ad1e), the 38 check runs at the head and the six Test Core shard logs plus the Check Changeset log; card #19886 (body and all 48 comments, in particular the release 5861981062, the claim 5862004488, the dev report 5863108817 and the seat ACCEPT 5863124263, the 2e release 5860028604); the 2f record 5861792004 on PR #20346; validate-sharing-rule-enforceability.ts at base and head in full (effectiveSharingModelOf, masterOf, anchorFindings, listHoldingFinding, the lazy anchorGraph, the main loop), validate-rls-predicate-enforceability.ts 960–1120 (listHoldingDeclaration, listHoldingComparisons, loweredSites), object-graph.ts (indexObjectGraph, graphObjectOf, graphFieldOf), authoring-rules.ts 1817–1837 (the rule row: commands: ALL, surfaces: CLI_ONLY), field-value.zod.ts 146–358 (MULTI_OPTION_TYPES, STRUCTURED_JSON_TYPES, MULTI_CAPABLE_TYPES, isMultiValueField), sql-driver.ts 2700–2730 (crossFieldComparisonClass), sharing-rule-service.ts 1200–1275 (findMatchingRows, matchRecord), sharing-plugin.ts 172–210 and 570–860 (backfillRuleGrants, init, the kernel:ready seeding and hook binding), bootstrap-declared-sharing-rules.ts 100–330; the entry 18.cel-predicate-one-value-comparand-refused.ts; this PR's changeset; check-changeset-no-major.mjs, check-adr-0087-registration.mjs, ablation-replace.mjs, ablation-dist-preflight.mjs, os-verify-lock.sh, check-expected-skips.mjs; AGENTS.md 255–300, 600–640 and 1060–1100; the docs-drift comment on the PR and content/docs/deployment/validating-metadata.mdx 496–506; the dev's probe scripts under issue-19886g/ as leads only. NOT MEASURED: live PostgreSQL, MySQL and mongod; driver-turso's remote transport; the sharing_rule save door (item 10, named only).

① Derived judgments

  • 1. P1, the runtime — RIGHT. Through the real SharingServicePlugin (init, start, the kernel:ready handlers, which seed through bootstrapDeclaredSharingRules from registerApp's sharingRules collection and bind the rule hooks, then the kernel:bootstrapped backfill) over a real ObjectQL, with 20 leak rules and 6 controls each sharing READ with its own user on a private object, 3 rows seeded before boot (one whose status is a member of tags and labels), then a post-boot insert and a post-boot update. The leak rules: json list under != (both orders), ==, negated ==, negated !=, greater-than, less-than list-first; json object under !=, == object-first, greater-or-equal; multiple lookup under == list-first, !=, negated greater-than; multiselect under !=, ==, less-or-equal list-first; multiple user under less-or-equal, != list-first; a list against a list; a compound and. driver-sql (better-sqlite3): 26 of 26 rules SEEDED into sys_sharing_rule with criteria_json ({"status":{"$ne":{"$field":"tags"}}}, {"$not":{"status":{"$eq":{"$field":"tags"}}}}, {"$and":[{"stage":"open"},{"status":{"$ne":{"$field":"tags"}}}]}); after the backfill 0 grants for every one of the 20 leak rules and every leak recipient reads 0 rows; after the insert and update still 0 grants and 0 rows for all 20. The door that answers: the sweep's own call, engine.find with the seeded criteria, is refused INVALID_FILTER / 400 for all 20, the first frame in packages/drivers/driver-sql (unsupportedFilterError), the withheld diagnostic naming the declaration: "tags" (type "json") has no scalar stored column a comparison can read, "reviewers" (type "lookup", multiple), "labels" (type "multiselect"), "watchers" (type "user", multiple). The WARN: findMatchingRows catches the refusal and logs [sharing-rule] criteria query failed with rule set, exactly 20 lines at the backfill, one per leak rule, 0 naming a control; after the two writes the sharing layer logs nothing (matchRecord swallows a refused read for a non-field rule) and the engine logs 40 Find operation failed warns naming no rule. sys_record_share held 10 rows after boot and 14 after the writes, all of them the controls'. Controls, boot then after the writes: text != text grants and the recipient reads exactly r1, r2, then r1, r2, r4; text == text r3, r3; number greater-than number r1, then r1, r4; negated text == text r1, r2, then r1, r2, r4; the literal r2, then r4 (r2's status moved to closed); json != null r1, r2, r3, then all four. driver-sqlite-wasm: identical, cell for cell, including the 20 WARNs and the 40 unnamed engine warns. driver-memory ([finding] driver-memory's own reference matcher has no $field arm — a cross-field comparand (bare or with addDays) reaching it is presumably compared as a literal object rather than resolved or refused (grep reading, to be measured) #15104, frozen): reads the { $field } comparand as a literal, so != grants every row (r1–r3, then r1–r4) for leak and scalar rules alike, == and ordering grant nothing, and the scalar controls C01–C04 are equally wrong — no shipped driver shares this class correctly, so the lint does not over-refuse against the runtime. P1 holds: a silent zero-share, declared and seeded and granting nothing.
  • 2. The arm refuses exactly the class, at the real os validate — RIGHT. One 232-cell stack (node packages/cli/bin/run.js validate --json, the built entry), in both trees. Leak: 12 operator spellings (==, !=, greater-than, greater-or-equal, less-than, less-or-equal, each plain and under !) × 5 column kinds (a json list, a json object, a multiple lookup, a multiselect, a multiple user) × 2 orders = 120, plus json-list against multiple-lookup, multiselect against multiple-user, a compound and, a compound or, and an active: false rule: 125. Controls: the 12 spellings × 4 scalar pairs (text vs text, number vs number, date vs date, a single lookup vs text) × 2 orders = 96. Neighbours: a json column against a literal, json != null, negated json == null, multiple lookup == null, a multiselect against a literal, a multiple user against a literal, a flat literal list, a single select against text: 8. Firing controls: a list literal, a function call, current_user: 3. Head: 125 of 125 leak cells refused with exactly one sharing-rule-unlowerable-condition error each, every message carrying lowers, but compares a field with a field that holds a list or an object; per spelling 10–12 of 10–12 for each of the twelve; per column kind 24–26 of 24–26 for each of the five; per order 60 of 60 each; the two both-list, two compound and the inactive cells all refused. 96 of 96 controls clean, 8 of 8 neighbours clean, 3 of 3 firing controls reported with the same rule id and, cell for cell, the same finding as at base. Base (control): 125 of 125 leak cells clean, controls and neighbours clean, the 3 firing controls identical. Movement base to head: 125 cells toward refusal, 0 toward acceptance. Under-refusal: none found. Over-refusal: none found in the 104 control and neighbour cells, and 0 findings over the example apps (item 5). The finding quotes the comparison as the lowering read it (a negated less-than on tags and status is quoted as the inner less-than comparison of record.tags with record.status, without the !) and the declaration (`tags` is declared `type: 'json'`; `reviewers` is declared `type: 'lookup'`, `multiple: true`); three message shapes across the 125 cells (one list column, one column whose declaration carries multiple: true, both columns list-holding) and one hint.
  • 3. One classification, not a copy — RIGHT. The arm's listHoldingFinding calls listHoldingComparisons(graph, object, filter) imported from ./validate-rls-predicate-enforceability.js; no second classifier exists in the sharing file. The RLS file's diff against the merge-base is +5/−2: interface ListHoldingComparison and function listHoldingComparisons each gain export, and one comment sentence (three comment lines) is added; nothing else moves. listHoldingDeclaration reads STRUCTURED_JSON_TYPES.has(type) then isMultiValueField({ type, multiple }), and the spec sets are json, composite, repeater, record, location, address, vector, multiselect, checkboxes, tags, and select, radio, lookup, user, file, image with multiple: true, the sets the changeset names; driver-sql's crossFieldComparisonClass refuses by isMultiValuedColumn and JSON_COLUMN_TYPES, the same two sets (the 2f record's 56-row parity table, not repeated here, transfers because the function is the same). The three RLS pin files are byte-identical at base and head (blobs da61febd, 6e728dc1, 1a0cdb43), and they pass at the head: 120 / 120, 11 / 11, 336 / 336.
  • 4. No pre-existing sharing verdict moves — RIGHT. validate-sharing-rule-enforceability.test.ts is byte-identical at base and head (blob dcfd2305). The six pre-existing pin files were run in both states with vitest's JSON reporter and compared test by test: 547 / 547 at base, 547 / 547 at head, 0 moved, 0 only at base, 0 only at head; the head run adds the new file's 64 / 64 for 611 / 611. The [finding] 9 raw .reference reads still answer undefined silently after #18503 routed the carrier through one arbiter — the measured residue of ruling E item 2 #18550 pin passes in both: at the head, anchorFindings still runs before the lowering arm, and the graph is indexed only after compileCelToFilter answers ok and only for that rule's anchor (indexObjectGraph({ objects: [target] }), memoised per anchor), so the controlled_by_parent detail with an object-valued master_detail carrier still throws from masterOf (measured in both states: TypeError: validate-sharing-rule-enforceability masterOf: reference is an object), never from the graph. One constructed neighbour moves at the unit level and is named in ③.
  • 5. Census (narrowing) — RIGHT. objectstack at 79a2ad1e, packages/** + examples/**, non-test (test, spec, fixture, .d.ts, CHANGELOG and dist paths excluded): the declared sharingRules[].condition strings are examples/app-showcase/src/security/sharing-rules.ts:76 (record.status == 'new'), :110 (record.stage == 'qualified' && record.company == 'Northwind') and packages/qa/downstream-contract/src/additional-domains.fixtures.ts:59 (record.stage == "customer"): 3, all field against literal, 0 field-to-field; examples/app-crm/objectstack.config.ts declares no sharing rules (its comment at line 102 says why). Every non-test record.X op record.Y literal in the two trees is a validation rule, a hook condition, a doc comment or a lint fixture, none a sharing condition, and 0 compare with a json / multiple column. cloud origin/main 96eb092: sharingRules / sharing_rule / SharingRule occur in one file, scripts/dev-local/verify-hotcrm-saas.mjs, as row-count prose; 0 declared sharing-rule conditions and 0 field-to-field condition strings. The real os validate --json at head over app-crm (0 errors / 9 warnings), app-multi-package (0 / 3), app-showcase (0 / 84) and app-todo (0 / 7): 0 sharing-rule-* findings, exit 0 each; at base the same four runs give the identical finding sets, rule and path for path. 0 new findings, and nobody moves.
  • 6. Pins bite — RIGHT. From the committed head (rule blob a95f00af on disk = HEAD, tree clean), scripts/ablation-replace.mjs replaced if (listHolding) findings.push(listHolding); with a guard that never pushes and carries the marker ABL_REVIEW_20375: anchor 1 to 0, marker 0 to 1, blob a95f00af to 7c738efc. pnpm --filter @objectstack/lint build exit 0; ablation-dist-preflight.mjs @objectstack/lint ABL_REVIEW_20375 exit 0, marker present in 4 built files. Red: the new pin 50 failed / 14 passed of 64 (red: every refusal cell, every declared class, the parsed tier, the compound, the inactive rule, the class-sentence parity, the anchor-beside case and the os validate rule-table case; green: the table-size check, the 11 controls, the multi-capable single-valued case, the not-judged case and the one-defect case), while the pre-existing sharing pin stayed 50 / 50. The real os validate over the 232-cell stack on the ablated dist: all 125 leak cells clean again, and all 232 cells identical, finding for finding, to the base run. Restore: ablation-replace restored the file (blob a95f00af = HEAD, git diff HEAD empty), lint rebuilt exit 0, preflight --absent exit 0 (marker absent from all 14 built files, working tree clean against HEAD); whole-tree git status --porcelain empty before the worktree was removed.

② Semver level

  • @objectstack/lint minor + **BREAKING** + Clause-②: no (narrowing) — RIGHT. The diff narrows a published accept set at an authoring door (validateSharingRuleEnforceability, wired commands: ALL, so os validate / os build / os lint) and moves no runtime source: the only source files are packages/lint/src/validate-sharing-rule-enforceability.ts and the two export keywords in validate-rls-predicate-enforceability.ts. AGENTS.md's post-task rule makes (narrowing) BREAKING, and check-changeset-no-major.mjs records the launch-window convention under which a breaking change ships as minor with the **BREAKING** banner and the ADR-0087 disposition as carriers; the changeset carries all three. The PR body's Clause-②: no matches the claim 5862004488, and the changeset's no (narrowing) carries the arm, the same split as 2d, 2e and 2f. check-changeset-no-major --base 67047171 --head HEAD: exit 0 (no major; the level axis has no PR payload locally); in CI Check Changeset is success and reads the declaration Clause-②: no.
  • not-required (already-registered cel-predicate-one-value-comparand-refused) — RIGHT. The entry's surface opens security.PermissionSet rowLevelSecurity[].using and .check, and sharingRules[].condition and names this class in the arm's own words: a field compared with another field (==, !=, or an ordering operator) where either column holds a list or an object on the record, as a json column or a multiple lookup does; its replacement carries the prescription: A field compared with a json or multiple field has no pushdown form: compare with a single-valued column, or move the condition into a validation rule or hook; its acceptanceCriteria asks the author to grep the condition of your sharing rules for a field compared with a json or multiple field. This PR adds no class, no prescription and no stored-metadata rewrite; it moves where the registered class is refused on a sharing rule. check-adr-0087-registration --self-test exit 0; --base 67047171 --head HEAD exit 0, reading the changeset as [BREAKING+clause-②-narrowing] not-required (already-registered). The entry's reason describes the 2d write-check outcome and not the sharing path's silent zero-share, as the PR's Acceptance notes say; surface and replacement are what the disposition rests on, and they fit.
  • Every changeset sentence — TRUE on my measurements. The door sentence is the wiring row (commands: ALL) and item 2; the "measured before this change" sentence is item 1 and the base column of item 2 (the dev's 72-cell table is a subset of my 125, all clean at base); the runtime sentence (seeded, INVALID_FILTER / 400, matched no record, no grant at boot or on a later insert or update, the recipient read nothing) is item 1 on both SQL drivers; "one WARN line per rule in the server log" is exactly true of the boot backfill (20 for 20) and the sharing layer is silent on the per-write path, where only unnamed engine warns appear — the PR's Acceptance notes say so, and the changeset does not claim otherwise; the classification sentence names the spec sets I read in item 3; the inactive-rule sentence is the inactive cell of item 2; the "Not changed" list is the 104 clean control and neighbour cells, the new pin's not-judged cases (passing at head), the byte-identical RLS pins and the CLI_ONLY wiring row; the census sentence is item 5; the "What to change" hint is the finding's hint, measured verbatim. The id sentence ("keeps the unlowerable id because the fix is the same rewrite ... the literal spelling ... is already reported under that id") is true: the list-literal firing control reports sharing-rule-unlowerable-condition in both trees.

③ Boundary flags

Implemented-by: claude/issue-19886-stage-2g-sharing-arm
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS

Blocking items: none. Every judgment in ① is RIGHT on my own measurements; the semver level, the ADR-0087 disposition and every changeset sentence in ② are right; ③ carries one unit-level cell no door can reach, the quieter per-write diagnostic, the three out-of-scope items and a clean landing state, none this PR's regression.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 04:38
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit eee0974 Sep 28, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19886-stage-2g-sharing-arm branch September 28, 2026 05:00
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…o fields of different comparison classes when it is authored (objectstack-ai#20347) (objectstack-ai#20403)

Fixes objectstack-ai#20347
Clause-②: yes (narrowing)

The spec half of the objectstack-ai#20347 triage split (`5862073027`), dispatched on
claim `5863797885`. Base `eee09742`, head `bef47d1d`. The engine half is
objectstack-ai#20355, which stays open and reads the export this PR adds. The
changeset declares `Clause-②: yes (narrowing)`, BREAKING, `minor` on
both `@objectstack/spec` (new exports, a widening) and
`@objectstack/lint` (a new authoring refusal, a narrowing).

## What changes

- **One classification, exported once**
(`packages/spec/src/data/filter-cross-field-comparison-class.ts`,
re-exported from `@objectstack/spec/data`, beside
`filter-text-operator-declared-type.ts`).
- Six classes (`CROSS_FIELD_COMPARISON_CLASSES`: `numeric`, `text`,
`boolean`, `date`, `datetime`, `time`) and three families with none
(`CROSS_FIELD_NO_CLASS_REASONS`: `list-or-object`, `file`, `formula`).
- `CROSS_FIELD_COMPARISON_TYPE_CLASSES` classifies every `FieldType`
member exactly once, by reference to the existing `field-value.zod.ts`
sets. Nothing is re-listed.
- Two pure verdicts. `crossFieldColumnVerdict(field)` answers one
declared column; `multiple: true` on a multi-capable type holds a list.
`crossFieldComparisonVerdict(left, right)` answers two: `comparable`,
`cross-class`, `no-class`, or `unjudged` for a type outside `FieldType`.
- It is lifted case for case from driver-sql's module-private
`crossFieldComparisonClass` (the objectstack-ai#5222 boundary). `sql-driver.ts` is
untouched: objectstack-ai#20355 rewires it, and PR objectstack-ai#20372 holds that file.
- **Parity with driver-sql, run against both**
(`packages/drivers/driver-sql/src/sql-driver-20347-cross-field-class-parity.test.ts`).
One object declares every `FieldType` member (49), plus the 6
multi-capable members flagged `multiple: true`. Every ordered pair (55 ×
55 = 3,025) is compiled as `{ a: { $eq: { $field: b } } }` on a real
`:memory:` SQLite driver. The driver's admit or refuse must equal
`crossFieldComparisonVerdict(a, b) === 'comparable'` on every pair. A
refusal counts only in the cross-field boundary's own withheld
`INVALID_FILTER` / 400 form (`withheldFilterDiagnosticOf` non-null),
never by prose.
- **The authoring door** (`packages/lint`).
- `validateRlsPredicateEnforceability` gains a cross-class arm.
`crossClassComparisons` reads the lowered filter's `{ $field }` sites
against the declared field map. It reports `rls-predicate-unenforceable`
for every comparison whose two columns are not `comparable`: `==`, `!=`,
`>`, `>=`, `<`, `<=`, either side, under `!` too.
  - It covers `using` and `check` on every operation.
- `validateSharingRuleEnforceability` reads the same function and
reports `sharing-rule-unlowerable-condition` on a sharing rule's lowered
`condition`.
- A comparison against a list or an object stays the existing objectstack-ai#19886
arm's finding, so no comparison is reported twice. The new arm runs
ahead of the engine-judge pass, like the list arm: one defect, one
finding.
- The finding names each comparison, each column's declared type and
class (or why it has none), and the clause's measured run-time
consequence. The hint lists every class with the declared types it
holds, derived from the spec table.

## Measured before (lint as on `main`), then after

Real `os validate` (`packages/cli/bin/run-dev.js validate` on a probe
stack), plus the real plugin-security + ObjectQL on driver-sql
(`better-sqlite3` `:memory:`, one RLS policy on a `text` / `number` /
`image` / `formula` object).

| predicate | `os validate` before | `find` (`using`) | insert (`check`)
| insert (`using` as check) | by-id update / delete (`using`) | `os
validate` after |
|:--|:--|:--|:--|:--|:--|:--|
| `record.status != record.amount` (text vs number) | valid, exit 0 |
`INVALID_FILTER` / 400 | admitted, stored | admitted, stored | 403 / 403
| `rls-predicate-unenforceable`, exit 1 |
| `record.status != record.photo` (text vs image) | valid, exit 0 | 400
| admitted, stored | admitted, stored | 403 / 403 | refused, exit 1 |
| `record.status != record.is_open` (text vs formula; the card's NOT
MEASURED cell) | valid, exit 0 | 400 | admitted, stored | admitted,
stored | 403 / 403 | refused, exit 1 |
| `record.amount > record.status` (number vs text) | — | 400 | 403 (JS
`5 > 'open'` is false) | 403 | 403 / 403 | refused (lint unit and door
pins) |
| control `record.status != record.note` (text vs text) | valid, exit 0
| rows `[r1]` | admitted | admitted | updated / deleted | valid, exit 0
|

The `check` rows on `insert` read the same at `os validate`: valid
before, `rls-predicate-unenforceable` after. Sharing-rule conditions,
measured at the real `os validate`, first with the arm ablated (the
before-state) and then restored: `record.status != record.amount` and
`record.status != record.photo` went from valid (exit 0) to
`sharing-rule-unlowerable-condition` (exit 1). The control
`record.status != record.note` stayed valid. At run time the seeded
rule's criteria query meets the same driver-sql refusal the list-holding
class meets (objectstack-ai#20375 measured that path).

The write-check answer is whatever JavaScript's comparison of the two
raw values gives, so the permissive side of the policy is the write.
That half is objectstack-ai#20355's.

## Census (expected 0): 0

A script over `git ls-files examples packages` (tests, fixtures, docs,
generated bundles excluded; 3,140 files at `bef47d1d`) extracts every
`using` / `check` / `condition` string literal: 163. It lowers each
through the real `compileCelToFilter` (RLS through `sqlPredicateToCel`
first); 105 lower. It then lists every `{ $field }` comparison: 2.
- `examples/app-showcase/src/data/hooks/index.ts:88`: `record.spent >
record.budget`, a hook condition, both `number`.
- `packages/lint/scripts/check-doc-formula-expressions.mjs:1396`:
`record.a > record.b`, a gate fixture.

Neither is an RLS predicate or a sharing-rule condition, and both are
same-class. The only programmatic predicate constant is
`OWNERSHIP_FLOOR_PREDICATE` (`created_by == current_user.id`), which is
not field-to-field. So no shipped policy or sharing condition moves, and
nothing re-grades to p1. The cloud repository was not in this session:
NOT MEASURED.

## Ablation (one-time proof, committed state `bef47d1d`)

Two ablations, both run from the committed state `bef47d1d`, each
through `scripts/ablation-replace.mjs`. That tool landed each mutation
(anchor count 1 to 0, blob changed) and restored it (the blob equals
`HEAD`, and `git diff HEAD` is empty). A shell `trap` re-checked each
restore by hash. The direction observed is the normal one: red.

1. **The lint arm.** The guard line in `crossClassComparisons` was
replaced with an unconditional `continue`, so the arm reports nothing.
`ablation-dist-preflight` found the marker in 4 built
`@objectstack/lint` files, so the mutation reached the `dist/` the CLI
consumes.
- lint unit, the four cross-class and list-holding files: **525 failed /
485 passed** of 1,010. Restored: **1,010 / 1,010 passed**.
- CLI integration `rls-policy-authoring-admission.test.ts`: **6 failed /
33 passed**. The 6 are exactly the new REFUSED rows. Restored: **39 / 39
passed**.
- Real `os validate`, 9 cells. Ablated: all nine exit 0 with no finding,
which is the before-state, sharing cells included. Restored: the 3 RLS
`using` cells, the 2 RLS `check` cells and the 2 sharing cells exit 1,
each with exactly one finding; both controls exit 0.
- On restore, `ablation-dist-preflight --absent` passed its `dist/`
reading (the marker is absent from all 14 built files). Its tree reading
exited 3 only because two untracked scratch files were present at that
moment; both are deleted now.
2. **The driver half of the parity pin.** Temporarily, never committed:
in `sql-driver.ts`'s `crossFieldComparisonClass`, `if (type === 'time')
return 'time'` was changed to return `'datetime'`. The parity test
imports driver source, so no build was needed. Result: **2 failed / 54
passed**. The two are `f_datetime` and `f_time`, naming exactly
`f_datetime vs f_time: spec says cross-class, driver admitted` and its
mirror. Restored: **56 / 56 passed**, blob equal to `HEAD`.

## Tests (at `bef47d1d`)

All at `bef47d1d`, after the last commit, on a shared box.
- `@objectstack/spec`
- `vitest run --project local src/data`: 103 files, **3,458 passed**, 1
todo. The new classification test contributes 19.
  - `typecheck` (tsc, scripts and the test layer): exit 0.
- `@objectstack/lint`
  - `pnpm test`: 115 files, **5,314 passed**.
  - `typecheck` (with the test layer): exit 0.
- `@objectstack/driver-sql`
- The parity test plus the two existing cross-field suites
(`sql-driver-cross-field-reference`,
`sql-driver-cross-field-conformance`): **221 passed**, 2 skipped. The
parity test alone: 56 passed, one test per probe column (55 × 55 pairs),
plus the coverage pin.
  - `typecheck`: exit 0.
- `@objectstack/cli`
- `--project integration test/rls-policy-authoring-admission.test.ts`,
the only CLI file touched (integration tier): **39 passed**, 9 of them
new.
  - `typecheck`: exit 0.
- The unit tier is declared to CI: no CLI source file and no unit-tier
file changed.
- Real `os validate` over the examples: `app-crm`, `app-multi-package`
and `app-todo` exit 0, with 0 `rls-predicate-*` / `sharing-rule-*`
findings. `app-showcase` is NOT MEASURED this way: its config imports
`@objectstack/connector-mcp`, which is outside this worktree's build
closure. Its security files are in the text census above.
- Spec generated artifacts: `check:generated` named `api-surface/` and
`export-origins/` stale, both additive only. Both were regenerated with
their generators, and `check:api-surface` and `check:export-origins` are
green.
- Gates: `dispatch-gates --ran` accounts for 88 of 88 derived families.
86 exited 0. Two are NOT MEASURED, and CI owns both:
- `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET: it needs a
full `pnpm build`.
- `check:type-check-debt`: its `--re-measure` passed the 400 s local
timeout. The kill left `packages/spec/dist` without declarations, so the
spec was rebuilt (64 `.d.ts`) before every lint, driver-sql and cli
reading above.
- The derivation warned that the tree is behind `origin/main` by one
family file (`scripts/cross-package-test-inputs.mjs`).
`check:cross-package-test-inputs` was run from this tree and is green.

## Decisions

- **Formula has no class, whatever its `returnType`.** That is
driver-sql's answer: a formula is virtual, with no column to reference.
The text-operator door reads `returnType`, but a column-to-column
comparison needs a column on both sides. The measured runtime agrees
(400 on the read).
- **The file family is refused by name.** That is driver-sql's answer
too (the ADR-0104 dual-encoding window), so `image == image` is refused
as well.
- **A type outside `FieldType` is `unjudged`.** A driver's aliases
(`integer`, `object`, the absent-type `string` default) stay layered in
the driver, as `field-value.zod.ts`'s header says every alias does.
objectstack-ai#20355's rewire keeps those aliases above the export. At the door, an
out-of-vocabulary type is Zod's to refuse, and the arm reports nothing.
- **Registry-injected columns are judged** by the definition the
registry provisions. `record.status != record.created_at` is refused
(text vs datetime), because the driver sees the same column. `id` has no
definition in the graph, so it is not judged.
- **Same rule ids as the list arm.** The author's edit is the same kind:
rewrite which two columns are compared.
- **Two existing pins changed**, one in each objectstack-ai#19886 list-holding test.
"A single-valued `file` field is one value" asserted *no finding at all*
for `record.status != record.subject` with `subject` a single `file`.
driver-sql refuses that comparison (the file family has no class), so
the no-finding reading was never the runtime's. Each pin now asserts
that the list arm stays silent and the class arm refuses once. `select`
/ `lookup` / `user` keep the no-finding pin.
- **File surface beyond the claim, both required by the dispatch.** The
driver-sql parity test: the classification can only be run "against
both" there, and it adds no line to `sql-driver.ts`. And
`validate-sharing-rule-enforceability.ts` plus its tests: the direction
covers sharing conditions, and that rule is where they are judged.

## Acceptance notes

- `listHoldingComparisons` still reads `STRUCTURED_JSON_TYPES` +
`isMultiValueField` directly. That is the same family as the export's
`list-or-object` reason, and the two agree by construction (pinned in
the spec test), but it is two spellings. Converging it onto
`crossFieldColumnVerdict` is the natural edit for whoever next touches
that function (carrier: objectstack-ai#20355 or the next objectstack-ai#19886-family change). Noted,
not filed.
- The metadata save door for a `sharing_rule` does not run
`validateSharingRuleEnforceability`, as objectstack-ai#20375 recorded. The new sharing
arm therefore shows at `os validate` / `os build` / `os lint` only, like
the list arm. Noted, not filed.
- The `check` consequence sentence describes today's write check, which
admits by raw comparison. When objectstack-ai#20355 moves the write check onto this
classification, that sentence changes in the same change (a code comment
at `crossClassConsequence` says so).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants