fix(rest): parse the dataset-query selection at the door, matching the analytics family - #17548
Conversation
`POST {basePath}/analytics/dataset/query` checked only that
`selection.measures` was a non-empty array, so every other member reached
`dataset-executor` unrefused while the sibling analytics routes lift the
identical failure to a 400 at their entry.
Measured first, because the card left it open: the dataset route's
`selection` is a `DatasetSelection`, NOT the `AnalyticsQuery` the siblings
parse. Seven of its eleven members declare exactly the AnalyticsQuery member
of the same name; four are dataset-only. The door therefore parses a
projection of the seven against `AnalyticsQuerySchema.pick(...)` — a
pull-back onto published text — and projects the four away rather than
refusing them.
Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt
Co-authored-by: Claude <noreply@anthropic.com>
…ind it Adds the shape measurement the card left open as a pinned test: a legal `DatasetSelection` is refused by `AnalyticsQueryRequestSchema` on `cube` and on all four dataset-only members, which is why the siblings' schema is projected from rather than reused. Covers both sides: the card's measured specimen (`not a range at all`) now answers 400 ANALYTICS_DATE_RANGE_UNRECOGNIZED without reaching the service, and a fully-loaded valid selection — all eleven members — still answers 200 with the caller's own object reaching `queryDataset` by identity. Replays every in-repo selection specimen through the door as the leniency sweep. Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude <noreply@anthropic.com>
`pnpm check:test-source-alias` reds on a `@objectstack/spec/*` dynamic import first paid inside a clocked window: this package resolves both specifiers through `dist/`, so the first call transforms that graph while a testTimeout runs. Side-effect imports at module top move the transform into collection, which vitest clocks against nothing. The dynamic calls are unchanged. Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c98195e56c583a090d6483ba296213dca3379549 && git checkout c98195e56c583a090d6483ba296213dca3379549
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6e3462df47865ebca358d662f71e22ee036b457b c9df758be35722dc16620801dc036a4b4edd359a && git checkout -B drift-repro 6e3462df47865ebca358d662f71e22ee036b457b && git merge --no-ff c9df758be35722dc16620801dc036a4b4edd359a
node scripts/docs-audit/affected-docs.mjs --json 6e3462df47865ebca358d662f71e22ee036b457b
|
Contract reviewReviewed head:
Tier: default judgment tier, self-review plus gates — 「余席条款②复核 = 默认判断档自审加门禁」. ① Derived judgments — and the re-grade, which is the substance hereThe card's claim declared My claim-time reasoning was: the card records that nobody measured whether the dataset route's
Machine predicate, exit captured by redirect before any pipe: ② Semver grading
③ Boundary flags
Independence
VerdictPASS. ③ of the pre-landing checks is satisfied at this head — 33 check runs, 28 success / 5 skipped, 0 not-green, commit status 派发席位 · Generated by Claude Code |
…real residue The rework's own docblock in `core/src/utils/analytics-date-range.ts` carried two falsifiable clauses, and both citations it added pointed at a card number a reader cannot open. Prose only — no behaviour, no assertion, no bump change. - `:216-218` said "the one caller that ... DISCARDS the message is the REST dataset door". Measured on this head: four non-test callers of `analyticsDateRangeUnrecognizedError(`, of which THREE replace the message — the dataset door (`rest/src/analytics-selection-door.ts:197`) and the two face-side array arms (`service-analytics/src/date-range-array-arm.ts:71`, `driver-memory/src/memory-analytics.ts:777`). Only core's own string resolver (`:260`) lets the sentence leave. The paragraph now names the set. - `:225-226` said "on EVERY REST analytics route the schema door refuses first and this never fires". The array arm is `z.tuple([z.string(), z.string()])` with bare bounds — no `.min(1)`, no format refinement (spec `data/analytics.zod.ts:501`) — so `['','']` passes the union and reaches this constructor at each face past every door; only its message is replaced. The paragraph now states what the door does refuse and names the residue it cannot. - Both `#17058` citations this diff added are now `PR #17548, the PR that landed that door for card #17058`. `GET /issues/17058` answers 404 on the credential that answers 200 for `17548`; the `analytics.zod.ts` site ships to npm (`packages/spec` `files[]` carries `src/**/*.zod.ts`), so it was published prose pointing at a dead link. Claude-Session: https://claude.ai/code/session_01KB5PFtxuy1x3dcR5gxudx6 Co-authored-by: Claude <noreply@anthropic.com>
objectstack-ai#18275) Fixes objectstack-ai#18232 Clause-②: no `packages/services/service-analytics/src/date-range-array-arm.ts` overwrote `err.message` with a SECOND wording for `ANALYTICS_DATE_RANGE_UNRECOGNIZED`. Its own comment justified that on two specific grounds — the shared sentence judged a bare STRING against the preset vocabulary, and it ended with "Refused at the schema". PR objectstack-ai#18230 removed both: `analyticsDateRangeRefusalMessage` describes a non-string by what is WRONG with it (`describeRefusedDateRange`) and takes `origin` as a required parameter. With the grounds gone the second wording is what the \objectstack-ai#5240 convention exists to prevent, so the arm now raises the shared sentence unchanged. Measured against `origin/main` at the branch point `1a02ef17d`, ⛔ not against the card's text — the card was filed against a tree that did not exist yet. The premise holds: `analyticsDateRangeRefusalMessage(input, origin: 'schema' | 'runtime')` is on `main` (spec `analytics.zod.ts`), the array arm is `z.tuple([z.string(), z.string()])`, and the overwrite was still there. ## Which halves this delivery covers | half of the card | status | |:--|:--| | the second wording for one condition | **DONE** | | header sentence ① — the array arm is "a bare `z.array(z.string())` with no length constraint" | **DONE** (now `z.tuple`, ruling A on \objectstack-ai#17598, landed by \objectstack-ai#18230) | | header sentence ② — "Tightening `AnalyticsDateRangeSchema` … is deliberately NOT done here" | **DONE** (it has been done, upstream) | | header sentence ③,⚠️ **not named by the card** — "`POST /analytics/dataset/query` … never Zod-parses it, so the schema door is BEHIND these faces" | **DONE** — measured false in the same paragraph: since PR \objectstack-ai#17548 that route parses `timeDimensions` through `AnalyticsQuerySchema.pick(…)` in `rest/src/analytics-selection-door.ts`, wired ahead of the executor in `rest-server.ts` | | the SAME defect in `driver-memory` | ⛔ **NOT DONE** — different package, see Acceptance notes | ## Driven, not concluded from shape Both wordings a caller actually receives for the same condition, through the real call path (`explicitDateRangeWindow`, the one reading all four faces in this package call), with `@objectstack/core` and `@objectstack/spec` resolved through their built `dist`: **Before** — `['2026-01-01']`, two different sentences: ``` [A] face: [service-analytics] dateRange ["2026-01-01"] is a 1-element array, not a window. An explicit window is the TWO-element array [start, end] … Refused (ANALYTICS_DATE_RANGE_UNRECOGNIZED / 400) rather than guessed: this package's four analytics faces read an odd-sized array three … [B] shared: dateRange must be a date-range preset name (today, …) or an explicit window is the two-element array [start, end] … ; received a 1-element array, not the two bounds [start, end]. Refused past the schema door, by the analytics reader that received it (…/ 400). IDENTICAL(A,B)? false (same for [], ['a','b','c'], [null,null], ['',''] — 5 shapes, 5 disagreements) ``` **After** — one wording, byte-for-byte, on all five shapes: ``` IDENTICAL(A,B)? true ×5 [C] core constructor message === [B]? true ×5 ``` The envelope never moved: `code` + `status` have always come from the one shared constructor, and the cross-package conformance kit judges exactly those two (`analytics-date-range-conformance.ts` `judgeRefusal`), so it is unaffected. ## Tests `packages/services/service-analytics/src/__tests__/date-range-array-arm-arity.test.ts` pinned the message with four `toContain`s on literals of the wording being removed. That pin is satisfied by ANY private wording that quotes the contract — which is what stood here. Replaced with the stronger pin: - **identity**, across all 4 faces × 4 shapes: the message IS `analyticsDateRangeRefusalMessage(range, 'runtime')`; - the clauses identity alone cannot vouch for (identity tracks the builder wherever it goes): what is wrong with what arrived, the two-bound contract, the single-day spelling, and the RUNTIME origin — with the schema-origin sentence and the `[service-analytics]` prefix as negative controls. ``` pnpm --filter @objectstack/service-analytics test Test Files 111 passed (111) · Tests 2399 passed (2399) pnpm --filter @objectstack/service-analytics typecheck exit 0 pnpm --filter @objectstack/core typecheck exit 0 pnpm lint (repo-wide eslint, no narrowing) exit 0 ``` **Ablation** (fix committed first; mutation proved on disk by marker count and `git hash-object` vs the HEAD blob; restored by `git checkout HEAD -- path`, proved by blob equality and an empty `git diff HEAD`). Predicted direction RED, observed RED: reinstating a second wording on the arity branch alone fails 2 of 23 — ``` AssertionError: ObjectQLStrategy.dateRangeBounds answered one element — the card's shape with a wording of its own: expected '[service-analytics] ABLATION second w…' to be 'dateRange must be a…' Tests 2 failed | 21 passed (23) ``` ⭐ The other 21 stayed green, which is the point: the `code`/`status` pins cannot see a second wording, and did not. ## Gates — denominator Re-run in full on the CURRENT head `cf1d1ae49` after the changeset note below, ⛔ not carried over from the earlier head. `node scripts/pm/dispatch-gates.mjs --commands` (derived in-process from the merge-base change set, ⛔ not from a hand-written diff): **64 derived · 64 run · 0 NOT MEASURED · 0 UNRUN**, reconciled with `--ran` carrying one recorded exit code per family. On the fresh worktree FIVE first exited **3 = PREREQUISITE NOT MET** (`check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content`, `check:type-check-debt` — they read built output); the workspace was built (`turbo run build`, 72/72, FULL TURBO off the shared cache) and all five then exited 0. Recorded as the single final code per family, ⛔ never as a pass. `pnpm --filter @objectstack/service-analytics test` re-run on this head: 111 files / 2399 tests passed. `pnpm check:slot-lookup` exit 0. `pnpm check:nul-bytes` exit 0, plus a direct control-character scan over the changed files (no matches). Exit codes captured before any pipe.⚠️ **Declared, ⛔ not closed:** that derivation prints a **STALE TREE** warning — this branch is at least 5 commits behind `origin/main`, and 4 of the files the family list is derived FROM changed across that range (`package.json`, `scripts/check-required-contexts.mjs`, `scripts/check-self-test-wired.mjs`, `scripts/pm/check-dispatch-gates.mjs`). So the 64 is this tree's answer, and a family added on `main` since the branch point would not appear in it. Closing that needs a merge of `origin/main`, which would move the diff under review mid-review — left to the seat deliberately rather than taken unilaterally. ## Clause ② — derived as `no`, with controls ⛔ Not from the word `export` and ⛔ not from `dist/index.js`. The published entry is `exports` → `./dist/index.{js,cjs}` (+ types) with `files: [dist, README.md, CHANGELOG.md]`, i.e. the single barrel `src/index.ts`; the derivation walks that barrel's re-export list transitively and collects every symbol reachable from it, at `origin/main` and at HEAD. - `@objectstack/service-analytics`: **56 → 56**, sets identical. `@objectstack/core`: **287 → 287**, sets identical. - The changed file is **not reachable from the barrel at all** — `explicitDateRangeWindow` is internal, so no published symbol moved. - Positive controls: `AnalyticsService` FOUND; `analyticsDateRangeUnrecognizedError` FOUND (core). - Negative controls: `arrayArmRefusal` (module-private) NOT reachable at either ref; a nonexistent name NOT reachable. - **Sensitivity control** — because "zero added `export` lines" is not an answer: an injected `export function` in the barrel moves the count **56 → 57** and is listed as added, while the same injection in the unreachable file is invisible. The instrument can see a new export; it is not seeing one here. (Injection proved on disk, restored, blob-equal.) - No new member on an already-exported class: the only non-comment change deletes a module-private function and replaces two `throw` expressions. The `@objectstack/core` diff is **comment-only** — every added/removed line begins with ` *`, checked mechanically. - The payload gains no key: `code`/`status`/`message` are unchanged in shape; only the `message` VALUE moves, and it moves ONTO the declared contract, which is the narrowing direction. `scripts/pm/check-widening-tells.mjs --declaration no` reads **NOT MEASURED** on this diff (no declared surface covers these four files) — recorded as such, ⛔ not as a pass. ## Changeset `patch` for both packages, and it now carries the upgrade note the envelope paragraph did not: for an ARRAY `dateRange` the shared sentence DESCRIBES the shape where the removed one echoed the value, so a log line that used to carry the offending array no longer does — for EVERY array shape this face refuses, measured on all five (`[null, null]` now reads `received an array with a non-string bound`; `['', '']`, where the description carries least, reads `received a two-element array`). A bare STRING `dateRange` is still quoted back. `@objectstack/core`'s edit is comment-only, but that JSDoc **is published**: `grep -c "Three of the four callers" packages/core/dist/index.d.ts` = 1 (positive control: the symbol itself, 3), so the shipped `.d.ts` bytes move and `skip-changeset` would be wrong. ## Acceptance notes **Reported to the dispatching seat for filing, ⛔ neither filed nor fixed here.**⚠️ Deliberately not filed: repo-scoped REST is the read channel on this session (`/search/issues` answers `403 — sessions are bound to their configured repositories`), so dedupe fell back to one targeted semantic search, and that search did not return \objectstack-ai#18232 itself — the known-must-hit control for this exact subject. A dedupe whose control misses is not a reading, and filing on it would be filing blind. 1. `packages/drivers/driver-memory/src/memory-analytics.ts` keeps a THIRD wording for this same condition — its own local `explicitDateRangeWindow` overwrites `err.message` with `[driver-memory] dateRange … `. Same contract (\objectstack-ai#5240, quoted from `analytics.zod.ts`), same envelope, different package. Out of this card's file surface; fixing it would add another package's test surface to this PR. 2. `describeRefusedDateRange` (`packages/spec/src/data/analytics.zod.ts`) cannot describe `['', '']`. Its comment states "Two bounds is the arity the contract asks for, so the only way such an array reaches a refusal is a bound that is not a string" — measurably false: every face also refuses an EMPTY string bound, and `@objectstack/core`'s own header names `['', '']` as exactly the residue that reaches these faces. The sentence an author gets is therefore "…must be … the two-element array [start, end] …; **received a two-element array**."⚠️ This PR makes that sentence visible at the `service-analytics` face (before it, the face said "has a bound that is not a date string (string)", which is self-contradictory in its own way). `packages/spec` is out of bounds for this card, so this is reported, ⛔ not fixed. **Noted, not filed:** `@objectstack/core`'s prose was updated in this PR to stay true about which callers still supply their own message — it named `service-analytics` as one of three, which this change falsifies. Carried here rather than left for a later reader. Generated by Claude Code in session `session_01URLHobLUJB9K1ABV6ofdjj`; branch `claude/issue-18232-analytics-second-refusal-wording`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Closes #17058
POST {basePath}/analytics/dataset/querychecked exactly one thing about the body it forwards — thatselection.measureswas a non-empty array — and forwarded everything else unexamined. Its two siblings,/analytics/queryand/analytics/sql, Zod-parse their body at the entry (runtime/src/domains/analytics.ts→assertAnalyticsQueryBody) and lift a malformed member to a 400 before the service is reached. One family, two postures, decided by which door the client knocked on.Triage's ruling, verbatim:
⭐ The measurement the card left open — taken first, and it changes the shape of the fix
The card was explicit that the decisive measurement had not been taken:
Answer: the shapes are not the same, and reusing the siblings' schema would have been a worse defect than the one being fixed.
selectionis aDatasetSelection(packages/spec/src/contracts/analytics-service.ts:177, published asDatasetSelection (interface)inpackages/spec/api-surface/contracts.json:74). Parsed againstAnalyticsQueryRequestSchema— the schema the siblings use — an ordinary, entirely legal dashboard selection fails twice over: that schema requirescube(a dataset selection never carries one; the dataset is addressed bybody.dataset/body.datasetName), and it is.strict(), soruntimeFilter,dateGranularity,compareToandtotalsall come back as unrecognized keys. That is pinned as a test rather than asserted — see §1 of the new suite.What is shared is member by member, and it is most of the shape. Seven of
DatasetSelection's eleven members declare exactly theAnalyticsQuerymember of the same name:DatasetSelectionAnalyticsQuerydimensionsstring[]?string[]?measuresstring[]string[]timeDimensionsAnalyticsQuery['timeDimensions']— declared by referenceorderRecord<string, 'asc' | 'desc'>?limit/offsetnumber?timezonestring?So the door parses a projection of those seven against
AnalyticsQuerySchema.pick(…), and projects the four dataset-only members away before parsing (.pick()carries.strict()through, so handing it the raw selection would reject them). That is triage's posture — parse at the door, lift to 400 — applied to the shape this route actually has, which is what the instruction asks for; forcing one schema onto a different shape is not.Both refusal shapes come from the family, including the code
timeDimensions[].dateRangerefusal ⇒400 ANALYTICS_DATE_RANGE_UNRECOGNIZED, the ADR-0112 envelope the sibling door answers for the identical condition. All-or-nothing, exactly asassertAnalyticsQueryBodylifts it.400 VALIDATION_FAILEDwithdetails.fields[], each entry naming the member asselection.followed by the member path, mapped throughzodIssuesToFields(the one ADR-0114 D3 implementation).⛔ The date-range code is read off
analyticsDateRangeUnrecognizedError(@objectstack/core, the platform's one constructor for this refusal) and never spelled as a literal inpackages/rest. Two reasons, both load-bearing. ADR-0112 D3 registers the code under@objectstack/runtimewith a recorded provenance waiver forcore's shared constructor, so a literal here would be a stamp site under an owner key that does not list it —check:error-code-provenancemeasured green with the delivered diff (scanned 2282 files; 334 registered-code stamp site(s): 317 listed, 17 waived). And the #5240 convention wants one condition to keep one wording, which a second spelling quietly ends. That constructor's own TSDoc already names this route as the caller it was waiting for:Validation-only: the caller's
selectionobject is what reachesqueryDataset, by identity, never a parse output — the ruleassertAnalyticsQueryBodyrecords for the same reason.Did anything in-repo rely on the leniency?
Triage asked for this as a measurement, not an impression. No. The sweep, with its controls:
selectionliteral POSTed at this route by an in-repo suite (packages/rest/src/analytics-dataset-{dimension-gate,where-gate,refusal-envelope,unlisted-refusal-envelope}.test.ts,analytics-routes.test.ts,analytics-filter-refusal-envelope.test.ts,analytics-16019-driver-declared-fault.test.ts), pluspackages/client/src/client.test.tsand the one dogfood caller (packages/qa/dogfood/test/temporal-storage-e2e.dogfood.test.ts:274), is replayed through the new door as a test — §5's last case. All pass.packages/rest's whole suite: 189 files, 3165 passed, 1 skipped, 0 failed.analytics-dataset-unlisted-refusal-envelope.test.ts:181,dateRange: ['2026-01-01', 'the-first-of-never']— the array arm, which the contract admits and the executor refuses deeper. It still reaches the executor and still refuses there.examples/,content/docs/andapps/carryruntimeFilteronly on authoring metadata (report/dashboard definitions the renderer lowers), never a wireselection. Controls on that sweep:grepfordatasetinexamples/returns 10 files; a fabricated key returns 0.⭐ The negative side — a valid selection must still pass
A door that refuses too much is a worse bug than the one being fixed, and it is what triage's "newly reachable 400" warning points at. §4 and §5 cover it: each of the four dataset-only members reaches the service untouched; a fully-loaded eleven-member selection answers 200 with the caller's own object arriving at
queryDatasetby identity; and the explicit[start, end]window arm is untouched by the closing.What this does NOT close, and why it is not widened here
The four dataset-only members (
runtimeFilter,dateGranularity,compareTo,totals) still have no door.DatasetSelectionis a TypeScript interface with no Zod schema anywhere in the repo — measured: zero hits for aDatasetSelection-shaped schema, withCursorSelectionSchemafiring as the positive control on the same grep and a fabricated name returning 0. Authoring one belongs inpackages/specbeside the interface (Prime Directive 1, Zod First), not in a consumer, where a second declaration of a spec-owned wire shape is exactly the dialect Prime Directive 12 exists to prevent.packages/specis read-only for this card, so it is reported rather than smuggled in — see the acceptance notes.Evidence
packages/rest/src/analytics-dataset-selection-door.test.tsc9df758b200againpackages/restfull suitepnpm --filter @objectstack/rest typecheckpnpm lint(eslint . --no-inline-config, whole repo, no narrowing)dispatch-gatesfamilies — 61 commands, derived twice (auto-derived from the tree, and with--pathsover all four changed paths; the two lists are identical)check:error-code-provenance(not in the derived set; added because the diff is a new refusal site)The ablation is symmetric and one-shot: mutate, prove the anchor count went 1 → 0 and the injected marker 0 → 1 on disk, run, restore via
git checkout HEAD --on the target file under atrap, and prove the restored blob hash equals the HEAD blob (efdd46f7…both sides). No permanent test file was left behind.Two families first reported
PREREQUISITE NOT MET(exit 3) —check:dual-build-cjs-loadsandcheck:type-check-debt, both of which read built output. Neither was read as a pass: the full closure was built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 72/72 successful) and both re-run to exit 0, then re-run once more at the delivered commit.Contract grade
Clause-②: no
Contract-text:the refusal set is exactly what the published interface already declared —DatasetSelection,packages/spec/src/contracts/analytics-service.ts:177-232, exported inpackages/spec/api-surface/contracts.json:74:Nothing this door refuses is admitted by that text; nothing that text admits is refused. The claim comment graded
yesconservatively — 「claim 拿不准 ⇒ 按yes挂标走席内契约复核」 — expressly pending this measurement, and the measurement drops it tono.needs:contract-reviewis hung on the PR regardless, per 「PR 一存在即挂」; the seat clears it.Acceptance notes
Out of scope, noted, not filed by this PR (the dedup channel is documented as not answering — the card's own control: a term query for
ListViewSchemareturns 0 while an open issue carries it in its title — so these are handed to the PM to file rather than filed on an unverifiable zero):DatasetSelectionhas no Zod schema, so its four dataset-only members have no door at any layer. The concrete consequence, measured:shiftRangeinpackages/services/service-analytics/src/dataset-executor.ts:568-580branches onkind === 'previousYear'and lets everything else fall through to thepreviousPeriodarm, socompareTo: { kind: 'nonsense' }silently returns a previous-period comparison under a200. Successor: whoever authorsDatasetSelectionSchemainpackages/spec.dateRangehalf behind the route rather than at it, and this PR is the door. Neighbours on the same route named by the card and not addressed here: service-analytics: a dataset measure with its ownfilter(or aratioover two of them) answers 501NOT_IMPLEMENTEDon driver-memory — the ObjectQL strategy hands the conditional aggregate todriver.find, which refuses it, and never falls back to the in-memory evaluator that supports it #16642, analytics: 数据集维度只解析 lookup 字段,user 字段维度返回原始用户 id —— 以人为轴的图表/报表标签是一串 id #16390,driver-memoryanalytics acceptstimeDimensions[].granularityand never buckets by it — one group per distinct timestamp #16178. Cards referenced for provenance only, with no work claimed against them: Analytics drivers (memory, SQL) aligndateRangestring handling to the closed preset vocabulary (driver half of #16041) #16322, spec: theyesterdayentry ofDATE_RANGE_PRESET_MACRO_WINDOWSprescribes a TWO-day window, so the refusal an author is handed selects today as well #17014.⛔ Left as a draft; not queued, not armed, not marked ready.
Generated by Claude Code