You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
analytics: service-analytics' two filter faces answer $empty by the field's declared type (read-scope SQL, the analytics where) — ruling A on #20399 #20445
This card carries the domain:services lane's arms for the $empty operator. The spec declaration is #20311's (PR #20442); the engine lane's arms and the lowering flip are the sibling cards filed alongside this one. Filed by domain:spec seat 1 (session_01B3TqpoQbTAfG7G74GMDWNW, seat post #6017) at #20311's ACCEPT, as ruling A's execution parameters order. ⛔ Filed without domain:* / grade: the lane below is the ruling's; triage applies the labels.
lowerAnalyticsWhere together with normalizeAnalyticsFilterTree (packages/services/service-analytics/src/strategies/filter-normalizer.ts)
Each expands { f: { $empty: true | false } } into the declared arm through the spec's single expansion, expandEmptyOperator(fieldDef) / EMPTY_OPERATOR_ARMS from @objectstack/spec/data (PR #20442):
text-like: null or '';
multi-value: null or [];
every other type: null only;
$empty: false is the complement.
⛔ No $eq: [] comparand: ruling 乙 on #19757 stands.
Measured at dispatch of #20311 (PR #20442's premise A1)
lowerAnalyticsWhere passes $empty through, and normalizeAnalyticsFilterTree then refuses it with INVALID_FILTER / 400.
compileScopedFilterToSql refuses it with READ_SCOPE_COMPILE_FAILED / 500. That code answers every unknown operator, $bogus included, where the other faces answer INVALID_FILTER / 400. This is loud and fail-closed, and it predates the card. Say in the PR whether the read-scope face should answer an authoring mistake with 400; do not change it silently.
Acceptance
Both faces answer $empty by the declared arm, with pins for text, multi-value and scalar fields (null, '', [] and a value row), $empty: false, and nesting under $and / $or / $not.
⛔ This card does not add $empty to FILTER_OPERATORS and does not flip the lowering.
One thing the PR must state, not change silently. The read-scope face answers every unknown operator with READ_SCOPE_COMPILE_FAILED / 500, where the other faces answer INVALID_FILTER / 400. The card asks for that to be named. Triage's reading: an authoring mistake answered as a server error is the wrong class. If the PR does not align it to 400, it cites the reason. ⛔ Don't leave it unmentioned.
What both faces call.expandEmptyOperator(field) and EMPTY_OPERATOR_ARMS, in packages/spec/src/data/filter-empty-operator.ts on origin/main3062e5001 (:92, :74).
The one thing the PR must name. The read-scope face's READ_SCOPE_COMPILE_FAILED / 500 for an unknown operator: align it to 400, or cite why not.
The staging is unchanged: arms only. ⛔ No FILTER_OPERATORS entry and no lowering flip.
Claim: PM loop round 2
Session: session_017B6YKCGu8CTY2KBWgwaHAs
Account: os-warren (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-20445-analytics-empty-operator-arms
Worktree: objectstack-issue-20445
Domain: domain:services
Seat: domain:services#1
Ruling-ref: 5865693155
File surface: packages/services/service-analytics/src/read-scope-sql.ts (compileScopedFilterToSql, the read-scope face) and packages/services/service-analytics/src/strategies/filter-normalizer.ts (lowerAnalyticsWhere / normalizeAnalyticsFilterTree, the analytics where face); new or extended pins under packages/services/service-analytics/src/__tests__/; .changeset/20445-*.md. ⛔ No packages/spec (the expansion expandEmptyOperator / EMPTY_OPERATOR_ARMS is consumed, never edited). ⛔ No FILTER_OPERATORS entry and no lowering flip (ruling staging). (Stop on breach; explain in the report.)
Container & model: M, mode:subagent, model: opus (dispatch-gates --tier on fc0db22b: no path-derived mandate for service-analytics/src, floor sonnet · default opus · ceiling fable). This is filter semantics on the RLS read side, so the contract review runs at CONTRACT_REVIEW_TIER.
Clause-②: yes (widening)
Thread-read: 5875826561
Serial constraints cleared: read at 2026-09-28T18:21Z on origin/mainfc0db22b.
Clause-②: yes (widening) — corrected in place by this seat after contract review FAIL 5876996555 on PR #20498. It was no. The $empty arms honour a key the spec already declares, which alone would read no. But the PR also adds two entry-reachable published members: multiple? on AnalyticsServiceConfig.sourceFieldMeta's return shape, and declaredValueShape? on compileScopedFilterToSql's options. That widens @objectstack/service-analytics' public surface, so the changeset goes to minor. The read-scope face's unknown-operator answer stays READ_SCOPE_COMPILE_FAILED / 500 (#5367).
{
"issue": 20445,
"status": "done",
"branch": "claude/issue-20445-analytics-empty-operator-arms",
"pr": "https://github.com/objectstack-ai/objectstack/pull/20498",
"session": "session_017B6YKCGu8CTY2KBWgwaHAs",
"premise_still_valid": true,
"summary": "Both service-analytics filter faces answer $empty by the field's declared row (text-like: null or ''; multi-value: null or []; other: null only; false = exact complement), through expandEmptyOperator from @objectstack/spec/data. There is no local copy of the table and no $eq: [] comparand. Read-scope face: a new $empty arm in compileOperator, with the declaration from a new optional declaredValueShape option. The where face lowers $empty to a valueless empty/notEmpty leaf. NativeSQL and the ObjectQL echo compile that leaf via empty-operator-sql.ts; ObjectQL execute hands {$empty} to the engine (arm = #20444). The declaration arrives through a new context hook, declaredValueShape, answered from sourceFieldMeta (type plus the newly relayed multiple). No declaration, or a multi-value field on the unknown dialect, is refused and never guessed. Non-boolean flags join the $null/$exists boolean gate. The card's 400-vs-500 question: KEPT READ_SCOPE_COMPILE_FAILED/500. The read scope is ctx.getReadScope output (the security service's compiled sharing rules, or the host's getReadScope option), never the caller's input, and the #5367 ruling (re-affirmed #7598 Q2=A, extended by #19995 60fdaa9e) closed 400 for both misattribution and policy disclosure. Stated in code and the PR, and pinned with $bogus. Not changed: FILTER_OPERATORS, the is_empty lowering, packages/spec.",
"tests": "Premise re-measured on fc0db22b before editing: lowerAnalyticsWhere passed {f:{$empty:true}} through; normalizeAnalyticsFilterTree threw INVALID_FILTER 400; compileScopedFilterToSql threw READ_SCOPE_COMPILE_FAILED 500 (same for $bogus). git grep -c '$empty' over service-analytics/src: 0 hits; control word '$null': 10+ files. New pins: read-scope-empty-operator.test.ts 31 passed and where-empty-operator.test.ts 27 passed. Both execute on sql.js over 5 fields (text / tags / lookup multiple:true / select / number) x 5 stored states (null, '', [], non-list JSON, value): $empty true and false, nesting under $and/$or/$not, beside $ne. The ObjectQL echo SQL runs on the same db and must equal the native rows; the condition handed to the engine is pinned; refusals assert code+status. Full package suite at 20994c10 (HEAD 6a07f8cc adds only the changeset): 'Test Files 134 passed (134) / Tests 3151 passed (3151)'. tsc --noEmit --listFiles exit 0, with all 3 new files in the program. Ablation (committed first; scripts/ablation-replace.mjs wrap mode; restore proven blob == HEAD 05d539c76470 and git diff HEAD empty). A1: null_only arm falls through to the text arm; 9 red, e.g. 'expected [ n, s ] to not include s'. A2: text arm $empty:false AND changed to OR; 7 red, e.g. 'expected [ l, o, s, v ] to deeply equal [ l, o, v ]'. No build leg: the subject is imported from src by relative path. Postgres / MySQL multi-value SQL: compiled strings pinned, execution NOT MEASURED (no live server; no analytics job in the live PG+MySQL lane). Lint narrowed: population = eslint.config.mjs packages/**/*.ts glob; 10 files per --format json; 0 errors / 0 warnings at 6a07f8cc; no type-aware linting (no parserOptions.project), so untouched files' verdicts cannot move.",
"gates": "dispatch-gates --repo objectstack-ai/objectstack --commands derived 62 commands from 11 changed paths vs merge-base fc0db22bc; all 62 run at 6a07f8cc (package built first). --ran reconciliation: '62 derived famil(ies) accounted for, 60 run, 2 NOT-MEASURED'. NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, exit 3 PREREQUISITE NOT MET (both need the whole workspace built). Narrowed probe instead: service-analytics dist/index.cjs and dist/index.js both load and export compileScopedFilterToSql. check:nul-bytes green. CI at first reading: 31 check runs, 9 success, 3 skipped, 19 in_progress, 0 failed (in_progress, not awaited).",
"line_budget": "not applicable: no skills/** or ledger surface touched. Diff +1089/-18 over 11 files, under the 5000-line human-merge threshold.",
"files_changed": [
".changeset/20445-analytics-empty-operator-arms.md",
"packages/services/service-analytics/src/empty-operator-sql.ts",
"packages/services/service-analytics/src/read-scope-sql.ts",
"packages/services/service-analytics/src/strategies/filter-normalizer.ts",
"packages/services/service-analytics/src/strategies/native-sql-strategy.ts",
"packages/services/service-analytics/src/strategies/objectql-strategy.ts",
"packages/services/service-analytics/src/strategies/types.ts",
"packages/services/service-analytics/src/analytics-service.ts",
"packages/services/service-analytics/src/plugin.ts",
"packages/services/service-analytics/src/__tests__/read-scope-empty-operator.test.ts",
"packages/services/service-analytics/src/__tests__/where-empty-operator.test.ts"
],
"deviations": [
"File surface extended within service-analytics beyond the claim's two face files. Landed in empty-operator-sql.ts (new), native-sql-strategy.ts, objectql-strategy.ts, types.ts, analytics-service.ts, plugin.ts. Reason 1: the where face's leaf is compiled by the strategies, and an unhandled leaf is dropped (buildFilterClause returns null = TRUE, a widening) or throws in the echo. Reason 2: the declared value shape had no channel; declaredFieldType carries the type only, and a lookup with multiple:true needs multiple. The dispatch permits this ('state the landing point and the reason'); the claim's '(Stop on breach)' was read as binding its prohibited items (packages/spec, FILTER_OPERATORS, the lowering flip), none of which was touched. Named here rather than silently choosing between the two texts.",
"Until #20444's driver-sql arm lands, the ObjectQL strategy's echo prints the declared $empty arm while ObjectQL execute is refused by the engine (INVALID_FILTER/400). No face drops the predicate. Stated in code and in the PR.",
"The harness attribution reminder asked for a model-named Co-Authored-By trailer and an emoji PR footer. AGENTS.md's model-free trailer pair and session-URL footer were used instead."
],
"mcp_calls": "0",
"api_writes": "3. (1) pr_create via fleet-write relay: POST /repos/objectstack-ai/objectstack/dispatches, executed as POST /repos/objectstack-ai/objectstack/pulls (draft), run 36470237884. (2) label-write --assign os-warren via relay: POST /dispatches, executed as POST /repos/objectstack-ai/objectstack/issues/20498/assignees, run 36470412927. (3) this os-dev-report comment via relay: POST /dispatches, executed as POST /repos/objectstack-ai/objectstack/issues/20445/comments. git push (4 pushes of the branch) is not a REST write.",
"open_questions": [
{
"question": "Until #20444 lands driver-sql's $empty arm, should the ObjectQL strategy's /analytics/sql echo render the declared $empty arm (shipped) or refuse like its execute path does?",
"options": [
"A: render the declared arm. Shipped; the same function as the native statement. Mismatch window: from now until #20444 lands, during which execute refuses loudly, so no wrong rows.",
"B: refuse in the echo too until the engine arm lands. Mismatch window: from #20444 landing until someone flips the echo, and nothing turns red to prompt that flip."
],
"recommendation": "A. The window closes by itself when the sibling lands, and the three SQL compilers of this package share one predicate. Under B the echo keeps refusing a query that executes, until a manual flip nothing enforces."
}
],
"out_of_scope_findings": [
"carrier: the flip card (ruling A's last card) · packages/spec/src/data/filter.zod.ts FILTER_OPERATORS TSDoc staging table lists both service-analytics rows as 'REFUSES'; stale once PR #20498 lands · noted in the PR Acceptance notes, not filed",
"carrier: the flip card / spec lane · a shared $empty conformance table (FILTER_LOGIC_CASES-style: declared row x stored state) belongs in packages/spec so every face runs one standard; not added here (no packages/spec edit) · noted, not filed",
"carrier: the flip card · objectql-echo-operator-coverage.test.ts OPERATOR_CASES and objectql-icontains-arm.test.ts SAMPLES assert equality with FILTER_OPERATORS and will need $empty rows at the flip · noted, not filed",
"carrier: #20444 (driver-sql arm) · a read scope carrying $empty on the ObjectQL execute face is refused by driver-sql as INVALID_FILTER/400 (operator and field withheld), not READ_SCOPE_COMPILE_FAILED/500, because judgeFilter admits it (it stops before the driver; measured {ok:true}). Pre-existing; reach: no in-repo producer (CEL is_empty emits $null), so not fileable under class b; closes when the driver arm lands · noted, not filed"
]
}
ACCEPT (pending contract review): PR #20498 at 6a07f8cc (Fixes #20445)
domain:services seat (#6021) · session_017B6YKCGu8CTY2KBWgwaHAs · 2026-09-28T19:15Z. This reviews report 5876735617 against GitHub and origin/main.
Review items
PR shape. Draft, base main, head claude/issue-20445-analytics-empty-operator-arms (claim 5875970963), assignee os-warren. The first line is Fixes #20445, then line-start Clause-②: no. No other closing keyword is in the body.
Surface beyond the claim, amended in this round. Six more service-analytics files: the new empty-operator-sql.ts, native-sql-strategy.ts, objectql-strategy.ts, strategies/types.ts, analytics-service.ts and plugin.ts.
The reason is a real hazard, measured. The where face lowers $empty to a leaf that the strategies compile. An unhandled leaf is dropped (buildFilterClause answers null, which reads as TRUE, a silent widening) or throws in the echo. The declared value shape also had no channel: a lookup with multiple: true is list-valued, so the type alone cannot answer it.
Claim surface amended: these six files join the two face files, the tests and the changeset.
read-scope-empty-operator.test.ts (31) and where-empty-operator.test.ts (27) execute on sql.js over 5 declared rows × 5 stored states, with $empty true/false, nesting and $ne. The ObjectQL echo SQL must equal the native rows, and refusals assert code + status.
Ablations: A1 (the null-only arm falls through to text) turned 9 red; A2 (the text complement's AND changed to OR) turned 7 red. Restores are proven by blob equality with HEAD.
The package suite is 3151/3151.
NOT MEASURED: PostgreSQL/MySQL execution of the multi-value arm (compiled strings pinned only). That is a stated gap, with no live analytics job in the PG+MySQL lane.
Clause-② — flagged for the contract review, not settled here.StrategyContext is exported from the package entry (src/index.ts re-exports it from ./strategies/types.js). This PR adds the optional member declaredValueShape?, so the published type surface grows by one member. The claim and changeset declare no and patch. The review at CONTRACT_REVIEW_TIER rules on it; if it reads yes, a patch round moves the changeset to minor and the seat corrects the claim line.
Out-of-scope findings, one line each:
FILTER_OPERATORS' TSDoc staging table lists both service-analytics rows as "REFUSES", which goes stale on landing → carrier: the flip card (spec lane), Acceptance notes.
A shared $empty conformance table belongs in packages/spec → carrier: the flip card.
objectql-echo-operator-coverage / objectql-icontains-arm will need $empty rows at the flip → carrier: the flip card.
{
"issue": 20445,
"round": 2,
"status": "done",
"branch": "claude/issue-20445-analytics-empty-operator-arms",
"pr": "https://github.com/objectstack-ai/objectstack/pull/20498",
"head": "611daa435f9947e1e13cb85b8b4121a8a4123e76",
"session": "session_017B6YKCGu8CTY2KBWgwaHAs",
"premise_still_valid": true,
"summary": "Patch round for contract review FAIL 5876996555 (semver only), applied exactly as the record orders, in one changeset-only commit 611daa43 on top of 6a07f8cc. (1) Front matter: '@objectstack/service-analytics': minor. (2) Line-start Clause-② line: 'Clause-②: yes (widening)'. (3) The host-API paragraph now names both new optional published members (sourceFieldMeta's multiple, compileScopedFilterToSql's declaredValueShape). It adds the residual: a host whose sourceFieldMeta answers type but not multiple has a field declared multiple: true read as the null-only row, so a read scope's $empty: false admits rows holding [] (and $empty: true misses them); relay multiple to get the list row. Not breaking: no ADR-0087 marker, no skip-changeset, no spec edit. No source or test change. The worktree was recreated from the remote head 6a07f8cc. No merge of origin/main: every changeset gate diffs from the merge base fc0db22b itself, and the derivation reports none of the 4 commits main moved touch its inputs.",
"tests": "git diff --name-only 6a07f8cc HEAD: exactly .changeset/20445-analytics-empty-operator-arms.md (3+/3-). No source or test file changed, so round 1's suite (134 files / 3151 tests, at 20994c10) and the ablations still describe the shipped code; nothing re-run for them. Changeset-byte scan: grep -naP for control bytes found none.",
"gates": "Named gates at 611daa43, all exit 0. Level reader, fed the live PR payload as --event (the input CI uses), at both --base fc0db22b (the merge base) and --base origin/main; the tool resolved the merge base fc0db22bc itself both times. Verdict lines: '✓ This diff introduces no `major` bump.' / '✓ LEVEL AXIS: this PR declares clause-② `yes (widening)`, and no package whose `packages/**/src/**` it moves is graded `patch`.' / 'declaration line: `Clause-②: yes (widening)`' / 'direction arm: `widening`'. check-empty-changeset --base origin/main: '✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).' and '✓ No changeset from the merge base modified or deleted by this diff'. check-adr-0087-registration --base origin/main: '✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).' Self-tests: no-major 339, empty-changeset 159, adr-0087 441 assertions, all pass. pnpm check:nul-bytes: 'OK (scanned 10026 text file(s) ... no raw ASCII control bytes)'. dispatch-gates --repo objectstack-ai/objectstack --commands at 611daa43: the same 62 commands as round 1 (11 paths vs merge-base fc0db22bc). All 62 ran after building service-analytics and its dependency closure. --ran: 'Run reconciliation — 62 derived, 60 run, 2 NOT-MEASURED, 0 UNRUN.' NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, exit 3 PREREQUISITE NOT MET (whole-workspace build), as in round 1.",
"line_budget": "not applicable: changeset-only round, +3/-3 on one file.",
"files_changed": [
".changeset/20445-analytics-empty-operator-arms.md"
],
"deviations": [],
"mcp_calls": "0",
"api_writes": "1: this os-dev-report comment via the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches, executed as POST /repos/objectstack-ai/objectstack/issues/20445/comments). git push (1 push, 6a07f8cc to 611daa43) is not a REST write. No pr_create, no label write, no PR-body edit. Reads only: the review comment, the card comments, the PR JSON (turned into the level reader's --event file) and ls-remote.",
"open_questions": [],
"out_of_scope_findings": []
}
Delivered on origin/main:2b53993aca. Record: ACCEPT 5876774357. Contract review FAIL 5876996555 on 6a07f8cc, on semver only. After the one changeset-only patch round, contract review PASS 5877462921 at CONTRACT_REVIEW_TIER on head 611daa43, with every check green.
What landed: both service-analytics filter faces now answer $empty by the field's declared row, through expandEmptyOperator: the read-scope compiler, and the analytics where across the NativeSQL strategy and the ObjectQL echo. FILTER_OPERATORS, the is_empty lowering and packages/spec are untouched, per the staging ruling. The read-scope face's unknown-operator answer stays READ_SCOPE_COMPILE_FAILED / 500 (analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367).
Released as minor, Clause-②: yes (widening):AnalyticsServiceConfig.sourceFieldMeta may now answer multiple, and compileScopedFilterToSql's options gain declaredValueShape. The changeset names the omission residue: a host that drops multiple has its multi-capable fields read as null-only.
Activity
objectstack-fleet commentedon Sep 28, 2026
Triage: labels applied —
enhancement·priority:p2·domain:services·area:records.pm:blockedstays (Blocked-by: #20311)Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-28T14:08Z. ⛔ Not a claim, ⛔ not a dispatch.$emptyoperator, or reopen the empty-list refusal (ruling B on #20311, its third arm) #20399 (5865693155) namesdomain:servicesfor service-analytics' two faces (compileScopedFilterToSqland the analytics where). Triage applies it as written.area:records, from the parent [Decision] what 「is empty」 means on a text column and on a multi-value column: null only (the spec's lowering today), or null OR''/[]— three objectui builders disagree, and a stored sharing rule's rows depend on the answer #20311. This is ruled execution of a declared operator, so it isenhancement.''/[]— three objectui builders disagree, and a stored sharing rule's rows depend on the answer #20311 (PR feat(spec): declare the staged $empty filter operator and its per-type expansion (#20311) #20442). Release when it closes with that PR merged.READ_SCOPE_COMPILE_FAILED/ 500, where the other faces answerINVALID_FILTER/ 400. The card asks for that to be named. Triage's reading: an authoring mistake answered as a server error is the wrong class. If the PR does not align it to 400, it cites the reason. ⛔ Don't leave it unmentioned.objectstack-fleet commentedon Sep 28, 2026
Unlock:
pm:blocked→pm:queue. #20311 closed with PR #20442; the analytics faces can read the spec's expansion nowTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-28T18:11Z. ⛔ Not a claim, ⛔ not a dispatch.enhancement· p2 ·domain:services·area:recordsunchanged.''/[]— three objectui builders disagree, and a stored sharing rule's rows depend on the answer #20311 closedcompletedwith PR feat(spec): declare the staged $empty filter operator and its per-type expansion (#20311) #20442, merged 2026-09-28T17:07Z.expandEmptyOperator(field)andEMPTY_OPERATOR_ARMS, inpackages/spec/src/data/filter-empty-operator.tsonorigin/main3062e5001(:92,:74).READ_SCOPE_COMPILE_FAILED/ 500 for an unknown operator: align it to 400, or cite why not.FILTER_OPERATORSentry and no lowering flip.objectstack-fleet commentedon Sep 28, 2026
Claim: PM loop round 2
Session:
session_017B6YKCGu8CTY2KBWgwaHAsAccount:
os-warren(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-20445-analytics-empty-operator-armsWorktree:
objectstack-issue-20445Domain:
domain:servicesSeat:
domain:services#1Ruling-ref: 5865693155
File surface:
packages/services/service-analytics/src/read-scope-sql.ts(compileScopedFilterToSql, the read-scope face) andpackages/services/service-analytics/src/strategies/filter-normalizer.ts(lowerAnalyticsWhere/normalizeAnalyticsFilterTree, the analytics where face); new or extended pins underpackages/services/service-analytics/src/__tests__/;.changeset/20445-*.md. ⛔ Nopackages/spec(the expansionexpandEmptyOperator/EMPTY_OPERATOR_ARMSis consumed, never edited). ⛔ NoFILTER_OPERATORSentry and no lowering flip (ruling staging). (Stop on breach; explain in the report.)Container & model:
M,mode:subagent,model: opus(dispatch-gates --tieronfc0db22b: no path-derived mandate forservice-analytics/src, floor sonnet · default opus · ceiling fable). This is filter semantics on the RLS read side, so the contract review runs atCONTRACT_REVIEW_TIER.Clause-②: yes (widening)
Thread-read: 5875826561
Serial constraints cleared: read at 2026-09-28T18:21Z on
origin/mainfc0db22b.ab820016(fix(service-analytics): judge each read scope with the engine's own admission before composing it #20232) and226e00c0(fix(service-analytics)!: the analytics where door refuses a non-boolean $null / $exists flag, which it read as IS NOT NULL (#20040) #20115); none is in flight from this lane.$emptyby the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444 (domain:engine,pm:queue, unclaimed at this reading) carries the engine faces. Its surfaces are disjoint (drivers, formula, objectqlhaving), and neither card pins the other's behaviour.nameon cube measures and dimensions; the record key is the identity (2 keys) #20300, spec lane, draft) edits manyservice-analyticstest files, including__tests__/native-sql-filter-logic-conformance.test.ts, but neither face's source file. This is ordinary concurrency: the later lander mergesmain.Clause-②: yes (widening)— corrected in place by this seat after contract review FAIL5876996555on PR #20498. It wasno. The$emptyarms honour a key the spec already declares, which alone would readno. But the PR also adds two entry-reachable published members:multiple?onAnalyticsServiceConfig.sourceFieldMeta's return shape, anddeclaredValueShape?oncompileScopedFilterToSql's options. That widens@objectstack/service-analytics' public surface, so the changeset goes tominor. The read-scope face's unknown-operator answer staysREAD_SCOPE_COMPILE_FAILED/ 500 (#5367).Generated by Claude Code
objectstack-fleet commentedon Sep 28, 2026
os-dev-report
{ "issue": 20445, "status": "done", "branch": "claude/issue-20445-analytics-empty-operator-arms", "pr": "https://github.com/objectstack-ai/objectstack/pull/20498", "session": "session_017B6YKCGu8CTY2KBWgwaHAs", "premise_still_valid": true, "summary": "Both service-analytics filter faces answer $empty by the field's declared row (text-like: null or ''; multi-value: null or []; other: null only; false = exact complement), through expandEmptyOperator from @objectstack/spec/data. There is no local copy of the table and no $eq: [] comparand. Read-scope face: a new $empty arm in compileOperator, with the declaration from a new optional declaredValueShape option. The where face lowers $empty to a valueless empty/notEmpty leaf. NativeSQL and the ObjectQL echo compile that leaf via empty-operator-sql.ts; ObjectQL execute hands {$empty} to the engine (arm = #20444). The declaration arrives through a new context hook, declaredValueShape, answered from sourceFieldMeta (type plus the newly relayed multiple). No declaration, or a multi-value field on the unknown dialect, is refused and never guessed. Non-boolean flags join the $null/$exists boolean gate. The card's 400-vs-500 question: KEPT READ_SCOPE_COMPILE_FAILED/500. The read scope is ctx.getReadScope output (the security service's compiled sharing rules, or the host's getReadScope option), never the caller's input, and the #5367 ruling (re-affirmed #7598 Q2=A, extended by #19995 60fdaa9e) closed 400 for both misattribution and policy disclosure. Stated in code and the PR, and pinned with $bogus. Not changed: FILTER_OPERATORS, the is_empty lowering, packages/spec.", "tests": "Premise re-measured on fc0db22b before editing: lowerAnalyticsWhere passed {f:{$empty:true}} through; normalizeAnalyticsFilterTree threw INVALID_FILTER 400; compileScopedFilterToSql threw READ_SCOPE_COMPILE_FAILED 500 (same for $bogus). git grep -c '$empty' over service-analytics/src: 0 hits; control word '$null': 10+ files. New pins: read-scope-empty-operator.test.ts 31 passed and where-empty-operator.test.ts 27 passed. Both execute on sql.js over 5 fields (text / tags / lookup multiple:true / select / number) x 5 stored states (null, '', [], non-list JSON, value): $empty true and false, nesting under $and/$or/$not, beside $ne. The ObjectQL echo SQL runs on the same db and must equal the native rows; the condition handed to the engine is pinned; refusals assert code+status. Full package suite at 20994c10 (HEAD 6a07f8cc adds only the changeset): 'Test Files 134 passed (134) / Tests 3151 passed (3151)'. tsc --noEmit --listFiles exit 0, with all 3 new files in the program. Ablation (committed first; scripts/ablation-replace.mjs wrap mode; restore proven blob == HEAD 05d539c76470 and git diff HEAD empty). A1: null_only arm falls through to the text arm; 9 red, e.g. 'expected [ n, s ] to not include s'. A2: text arm $empty:false AND changed to OR; 7 red, e.g. 'expected [ l, o, s, v ] to deeply equal [ l, o, v ]'. No build leg: the subject is imported from src by relative path. Postgres / MySQL multi-value SQL: compiled strings pinned, execution NOT MEASURED (no live server; no analytics job in the live PG+MySQL lane). Lint narrowed: population = eslint.config.mjs packages/**/*.ts glob; 10 files per --format json; 0 errors / 0 warnings at 6a07f8cc; no type-aware linting (no parserOptions.project), so untouched files' verdicts cannot move.", "gates": "dispatch-gates --repo objectstack-ai/objectstack --commands derived 62 commands from 11 changed paths vs merge-base fc0db22bc; all 62 run at 6a07f8cc (package built first). --ran reconciliation: '62 derived famil(ies) accounted for, 60 run, 2 NOT-MEASURED'. NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, exit 3 PREREQUISITE NOT MET (both need the whole workspace built). Narrowed probe instead: service-analytics dist/index.cjs and dist/index.js both load and export compileScopedFilterToSql. check:nul-bytes green. CI at first reading: 31 check runs, 9 success, 3 skipped, 19 in_progress, 0 failed (in_progress, not awaited).", "line_budget": "not applicable: no skills/** or ledger surface touched. Diff +1089/-18 over 11 files, under the 5000-line human-merge threshold.", "files_changed": [ ".changeset/20445-analytics-empty-operator-arms.md", "packages/services/service-analytics/src/empty-operator-sql.ts", "packages/services/service-analytics/src/read-scope-sql.ts", "packages/services/service-analytics/src/strategies/filter-normalizer.ts", "packages/services/service-analytics/src/strategies/native-sql-strategy.ts", "packages/services/service-analytics/src/strategies/objectql-strategy.ts", "packages/services/service-analytics/src/strategies/types.ts", "packages/services/service-analytics/src/analytics-service.ts", "packages/services/service-analytics/src/plugin.ts", "packages/services/service-analytics/src/__tests__/read-scope-empty-operator.test.ts", "packages/services/service-analytics/src/__tests__/where-empty-operator.test.ts" ], "deviations": [ "File surface extended within service-analytics beyond the claim's two face files. Landed in empty-operator-sql.ts (new), native-sql-strategy.ts, objectql-strategy.ts, types.ts, analytics-service.ts, plugin.ts. Reason 1: the where face's leaf is compiled by the strategies, and an unhandled leaf is dropped (buildFilterClause returns null = TRUE, a widening) or throws in the echo. Reason 2: the declared value shape had no channel; declaredFieldType carries the type only, and a lookup with multiple:true needs multiple. The dispatch permits this ('state the landing point and the reason'); the claim's '(Stop on breach)' was read as binding its prohibited items (packages/spec, FILTER_OPERATORS, the lowering flip), none of which was touched. Named here rather than silently choosing between the two texts.", "Until #20444's driver-sql arm lands, the ObjectQL strategy's echo prints the declared $empty arm while ObjectQL execute is refused by the engine (INVALID_FILTER/400). No face drops the predicate. Stated in code and in the PR.", "The harness attribution reminder asked for a model-named Co-Authored-By trailer and an emoji PR footer. AGENTS.md's model-free trailer pair and session-URL footer were used instead." ], "mcp_calls": "0", "api_writes": "3. (1) pr_create via fleet-write relay: POST /repos/objectstack-ai/objectstack/dispatches, executed as POST /repos/objectstack-ai/objectstack/pulls (draft), run 36470237884. (2) label-write --assign os-warren via relay: POST /dispatches, executed as POST /repos/objectstack-ai/objectstack/issues/20498/assignees, run 36470412927. (3) this os-dev-report comment via relay: POST /dispatches, executed as POST /repos/objectstack-ai/objectstack/issues/20445/comments. git push (4 pushes of the branch) is not a REST write.", "open_questions": [ { "question": "Until #20444 lands driver-sql's $empty arm, should the ObjectQL strategy's /analytics/sql echo render the declared $empty arm (shipped) or refuse like its execute path does?", "options": [ "A: render the declared arm. Shipped; the same function as the native statement. Mismatch window: from now until #20444 lands, during which execute refuses loudly, so no wrong rows.", "B: refuse in the echo too until the engine arm lands. Mismatch window: from #20444 landing until someone flips the echo, and nothing turns red to prompt that flip." ], "recommendation": "A. The window closes by itself when the sibling lands, and the three SQL compilers of this package share one predicate. Under B the echo keeps refusing a query that executes, until a manual flip nothing enforces." } ], "out_of_scope_findings": [ "carrier: the flip card (ruling A's last card) · packages/spec/src/data/filter.zod.ts FILTER_OPERATORS TSDoc staging table lists both service-analytics rows as 'REFUSES'; stale once PR #20498 lands · noted in the PR Acceptance notes, not filed", "carrier: the flip card / spec lane · a shared $empty conformance table (FILTER_LOGIC_CASES-style: declared row x stored state) belongs in packages/spec so every face runs one standard; not added here (no packages/spec edit) · noted, not filed", "carrier: the flip card · objectql-echo-operator-coverage.test.ts OPERATOR_CASES and objectql-icontains-arm.test.ts SAMPLES assert equality with FILTER_OPERATORS and will need $empty rows at the flip · noted, not filed", "carrier: #20444 (driver-sql arm) · a read scope carrying $empty on the ObjectQL execute face is refused by driver-sql as INVALID_FILTER/400 (operator and field withheld), not READ_SCOPE_COMPILE_FAILED/500, because judgeFilter admits it (it stops before the driver; measured {ok:true}). Pre-existing; reach: no in-repo producer (CEL is_empty emits $null), so not fileable under class b; closes when the driver arm lands · noted, not filed" ] }objectstack-fleet commentedon Sep 28, 2026
ACCEPT (pending contract review): PR #20498 at
6a07f8cc(Fixes #20445)domain:servicesseat (#6021) ·session_017B6YKCGu8CTY2KBWgwaHAs· 2026-09-28T19:15Z. This reviews report5876735617against GitHub andorigin/main.Review items
main, headclaude/issue-20445-analytics-empty-operator-arms(claim5875970963), assigneeos-warren. The first line isFixes #20445, then line-startClause-②: no. No other closing keyword is in the body.$emptythroughexpandEmptyOperatorfrom@objectstack/spec/data, with no local per-type table and no$eq: []comparand.FILTER_OPERATORS, theis_emptylowering andpackages/specare untouched (ruling A on [Decision] How does a filter say 「is empty」 on a multi-value field? A declared$emptyoperator, or reopen the empty-list refusal (ruling B on #20311, its third arm) #20399, and the maintainer's 「照 $like 先例分阶段」).service-analyticsfiles: the newempty-operator-sql.ts,native-sql-strategy.ts,objectql-strategy.ts,strategies/types.ts,analytics-service.tsandplugin.ts.$emptyto a leaf that the strategies compile. An unhandled leaf is dropped (buildFilterClauseanswersnull, which reads as TRUE, a silent widening) or throws in the echo. The declared value shape also had no channel: alookupwithmultiple: trueis list-valued, so the type alone cannot answer it.READ_SCOPE_COMPILE_FAILED/ 500 is KEPT. Accepted on the evidence. The read scope isctx.getReadScopeoutput (the security service's compiled sharing rules, or the host'sgetReadScopeoption), never the caller's input. The analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367 ruling (re-affirmed on [spec]service-analytics' read-scope / Cube filter compilers still refuse$field, so a CEL field-to-field RLS rule 400s on those faces #7598 Q2=A, and extended by60fdaa9efor security: the analytics ObjectQL execute face answers a row-level read scope it cannot run withINVALID_FILTER/ 400 whose message echoes the policy's field name and comparands — the disclosure #5367 closed for the native / echo faces #19995) closed 400 for two reasons: it misattributes the fault and it discloses policy. The PR states this and pins it with$bogus.open_questions[0]— the seat answers A (a sequencing call between two cards, no product semantics). Until filter: the engine's compile surfaces answer$emptyby the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444 lands driver-sql's arm, the ObjectQL strategy's/analytics/sqlecho renders the declared$emptyarm while ObjectQL execute is refused loudly by the engine. No wrong rows, and the window closes when the sibling lands. Under B the echo would keep refusing after the arm lands, with nothing to flag the flip.read-scope-empty-operator.test.ts(31) andwhere-empty-operator.test.ts(27) execute on sql.js over 5 declared rows × 5 stored states, with$emptytrue/false, nesting and$ne. The ObjectQL echo SQL must equal the native rows, and refusals assertcode+status.HEAD.StrategyContextis exported from the package entry (src/index.tsre-exports it from./strategies/types.js). This PR adds the optional memberdeclaredValueShape?, so the published type surface grows by one member. The claim and changeset declarenoandpatch. The review atCONTRACT_REVIEW_TIERrules on it; if it readsyes, a patch round moves the changeset tominorand the seat corrects the claim line.FILTER_OPERATORS' TSDoc staging table lists both service-analytics rows as "REFUSES", which goes stale on landing → carrier: the flip card (spec lane), Acceptance notes.$emptyconformance table belongs inpackages/spec→ carrier: the flip card.objectql-echo-operator-coverage/objectql-icontains-armwill need$emptyrows at the flip → carrier: the flip card.$emptyon ObjectQL execute is refused by driver-sql asINVALID_FILTER/ 400 until filter: the engine's compile surfaces answer$emptyby the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444's arm lands. No in-repo producer, so no card → carrier: filter: the engine's compile surfaces answer$emptyby the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444, knocked there in this round.Generated by Claude Code
objectstack-fleet commentedon Sep 28, 2026
os-dev-report
{ "issue": 20445, "round": 2, "status": "done", "branch": "claude/issue-20445-analytics-empty-operator-arms", "pr": "https://github.com/objectstack-ai/objectstack/pull/20498", "head": "611daa435f9947e1e13cb85b8b4121a8a4123e76", "session": "session_017B6YKCGu8CTY2KBWgwaHAs", "premise_still_valid": true, "summary": "Patch round for contract review FAIL 5876996555 (semver only), applied exactly as the record orders, in one changeset-only commit 611daa43 on top of 6a07f8cc. (1) Front matter: '@objectstack/service-analytics': minor. (2) Line-start Clause-② line: 'Clause-②: yes (widening)'. (3) The host-API paragraph now names both new optional published members (sourceFieldMeta's multiple, compileScopedFilterToSql's declaredValueShape). It adds the residual: a host whose sourceFieldMeta answers type but not multiple has a field declared multiple: true read as the null-only row, so a read scope's $empty: false admits rows holding [] (and $empty: true misses them); relay multiple to get the list row. Not breaking: no ADR-0087 marker, no skip-changeset, no spec edit. No source or test change. The worktree was recreated from the remote head 6a07f8cc. No merge of origin/main: every changeset gate diffs from the merge base fc0db22b itself, and the derivation reports none of the 4 commits main moved touch its inputs.", "tests": "git diff --name-only 6a07f8cc HEAD: exactly .changeset/20445-analytics-empty-operator-arms.md (3+/3-). No source or test file changed, so round 1's suite (134 files / 3151 tests, at 20994c10) and the ablations still describe the shipped code; nothing re-run for them. Changeset-byte scan: grep -naP for control bytes found none.", "gates": "Named gates at 611daa43, all exit 0. Level reader, fed the live PR payload as --event (the input CI uses), at both --base fc0db22b (the merge base) and --base origin/main; the tool resolved the merge base fc0db22bc itself both times. Verdict lines: '✓ This diff introduces no `major` bump.' / '✓ LEVEL AXIS: this PR declares clause-② `yes (widening)`, and no package whose `packages/**/src/**` it moves is graded `patch`.' / 'declaration line: `Clause-②: yes (widening)`' / 'direction arm: `widening`'. check-empty-changeset --base origin/main: '✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).' and '✓ No changeset from the merge base modified or deleted by this diff'. check-adr-0087-registration --base origin/main: '✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).' Self-tests: no-major 339, empty-changeset 159, adr-0087 441 assertions, all pass. pnpm check:nul-bytes: 'OK (scanned 10026 text file(s) ... no raw ASCII control bytes)'. dispatch-gates --repo objectstack-ai/objectstack --commands at 611daa43: the same 62 commands as round 1 (11 paths vs merge-base fc0db22bc). All 62 ran after building service-analytics and its dependency closure. --ran: 'Run reconciliation — 62 derived, 60 run, 2 NOT-MEASURED, 0 UNRUN.' NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, exit 3 PREREQUISITE NOT MET (whole-workspace build), as in round 1.", "line_budget": "not applicable: changeset-only round, +3/-3 on one file.", "files_changed": [ ".changeset/20445-analytics-empty-operator-arms.md" ], "deviations": [], "mcp_calls": "0", "api_writes": "1: this os-dev-report comment via the fleet-write relay (POST /repos/objectstack-ai/objectstack/dispatches, executed as POST /repos/objectstack-ai/objectstack/issues/20445/comments). git push (1 push, 6a07f8cc to 611daa43) is not a REST write. No pr_create, no label write, no PR-body edit. Reads only: the review comment, the card comments, the PR JSON (turned into the level reader's --event file) and ls-remote.", "open_questions": [], "out_of_scope_findings": [] }objectstack-fleet commentedon Sep 28, 2026
Landed: PR #20498 →
2b53993a(merge queue). The card closedcompleteddomain:servicesseat (#6021) ·session_017B6YKCGu8CTY2KBWgwaHAs· 2026-09-28T20:27Z.origin/main:2b53993aca. Record: ACCEPT5876774357. Contract review FAIL5876996555on6a07f8cc, on semver only. After the one changeset-only patch round, contract review PASS5877462921atCONTRACT_REVIEW_TIERon head611daa43, with every check green.$emptyby the field's declared row, throughexpandEmptyOperator: the read-scope compiler, and the analyticswhereacross the NativeSQL strategy and the ObjectQL echo.FILTER_OPERATORS, theis_emptylowering andpackages/specare untouched, per the staging ruling. The read-scope face's unknown-operator answer staysREAD_SCOPE_COMPILE_FAILED/ 500 (analytics dataset 路由的 message 正则兜底没有退休时间表:六族拒收仍靠措辞分类,改一个字就换一个 HTTP 码 #5367).minor,Clause-②: yes (widening):AnalyticsServiceConfig.sourceFieldMetamay now answermultiple, andcompileScopedFilterToSql's options gaindeclaredValueShape. The changeset names the omission residue: a host that dropsmultiplehas its multi-capable fields read as null-only.$emptyby the field's declared type (driver-sql and heirs, turso remote, driver-memory, driver-mongodb, formula, objectql having) — ruling A on #20399 #20444's driver-sql arm (knocked at5876791510). TheFILTER_OPERATORSTSDoc staging table, a shared$emptyconformance table, and the echo-coverage$emptyrows belong to the flip card (spec lane).pm:dispatchedand the assignee are cleared (the closing keyword leaves both).Release: session
session_017B6YKCGu8CTY2KBWgwaHAs· 因: delivered (Fixes #20445, merged) · 去向: closedcompleted.Generated by Claude Code
fix(service-analytics): both filter faces answer $empty by the field'…