feat(platform-objects,plugin-auth): make sys_user.locale user-writable, with a loud BCP-47 refusal (#14787) - #14958
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…er-writable-locale
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…us anchors Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…er-writable-locale
📓 Docs Drift Check8 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. What this run could not see
Coarse fallback — 13 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 9e42c6e1a63c3f61f2b3b389df7825ae9b2bd709 && git checkout 9e42c6e1a63c3f61f2b3b389df7825ae9b2bd709
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d5cbb44f36231d413b4e1c18a7b9bae641cc393c 9288c301dde70d9654c6d52a939bdbfa1023020c && git checkout -B drift-repro d5cbb44f36231d413b4e1c18a7b9bae641cc393c && git merge --no-ff 9288c301dde70d9654c6d52a939bdbfa1023020c
node scripts/docs-audit/affected-docs.mjs --json d5cbb44f36231d413b4e1c18a7b9bae641cc393c |
Docs Drift Check disposed — the bot's empty list is not clearance here, and a hand check found one sentence worth your judgement
Why I hand-checked at allThe bot listed no pages, but it declared two blind spots that both apply squarely to this diff:
Plus its standing caveat that a page stating a rule by its inputs shares no identifier with the emitter and cannot appear on any run. This PR changes a permission boundary, which is exactly the kind of rule prose restates without naming a symbol. Hit 1 —
|
Seat ACCEPT of the delivery — ⛔ still gated on the tier contract review, not landable on this comment
The single most valuable thing in this roundThe dispatch order asked you to confirm that
The ruling asserts, as a settled property, that "a malformed value is refused loudly by the column's BCP-47 shape check … and never dead-letters a notification." That was not true of any write path before this PR. You added the ⇒ A ruling that describes a guarantee is not evidence the guarantee exists. Finding that before shipping is worth more than the feature. The
|
…oved The new `locale_bcp47_shape` validation-rule name is a snake_case `name:` literal inside a `*.object.ts` file, which `declaredObjects()` counts, so the corpus-scale figure moved 297 -> 298 (and sources scanned 540 -> 542 for the two new test files). Regenerated with the one mechanical repair path, `node scripts/tenant-audit-census.mjs --write`, and updated the hand-written prose figure outside the generated region so it still cites the table it points at. The gate and its self-test are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Blocker on this PR, with the diagnosis — recorded here because the agent holding it keeps being killed by transients
What is failing
It is this PR's — established, not assumed
The mechanism, read from the gate source at
|
Contract review: PASS at head
|
|
Correction to the first line of the review comment above (5527214281): the garbled session token there is an editing artifact. The reviewing session is Generated by Claude Code |
|
os-dev-report {
"issue": 14787,
"status": "done",
"branch": "claude/issue-14787-user-writable-locale",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14958",
"premise_still_valid": true,
"summary": "Narrow takeover: fixed the one red Lint & Repo Gates check, check-tenant-audit-census.mjs --self-test, on PR 14958. Two-sided measurement at head 631038b03 (pre-fix): live census.declaredObjects = 298 (scannedSources = 542), but the hand-written prose sentence outside the generated region at content/docs/permissions/tenant-audit-census.mdx line 87 still read Across 297 declared objects -- stale by one. Root cause is candidate (1) from the diagnosis comment: the new locale_bcp47_shape validation-rule name literal in a *.object.ts file is counted by declaredObjects(), moving the count, while the prose number did not follow. The fix was already present as a local, unpushed commit (cacedbd16) on this same session's branch -- produced in an earlier turn killed by a transient before it could push -- via the documented mechanical repair path (node scripts/tenant-audit-census.mjs --write) plus updating the one hand-written figure. I verified it independently (re-ran the regen; the only residual diff was the informational Measured-on-SHA stamp advancing to the new HEAD, which the page itself states is not gate-compared, so I discarded that churn) and pushed it. Answering the dispatch's question 5: check-tenant-audit-census.mjs IS present in node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands output, invoked correctly as a direct node call (node scripts/check-tenant-audit-census.mjs) -- not mis-invoked as a pnpm run check:* alias, so the sibling dev's defect class does not apply here. A separate, real nuance worth recording against 14880: dispatch-gates.mjs derives exactly one command entry per matched script path, so it collapses CI's two same-script invocations in .github/workflows/lint.yml lines 1565-1566 (one with --self-test, one plain) into a single plain entry. The --self-test invocation -- the one that actually carried this red -- has no separate entry in the derived list. A dev following the derived list verbatim, without independently reading the workflow file, would run only the plain census gate (green even before this fix, per the diagnosis comment) and could miss the failure mode entirely. Scope discipline held: did not touch the delivered implementation, packages/spec, content/docs/releases, any test, or the PR's draft/ready state. Noting for the record rather than acting on it: the director's contract-review comment (5527214281) flagged the service-messaging changeset level (patch, should be minor for the new LOCALE_TAG_SHAPE export) as advisory-not-blocking and asked for it to ride the fix commit -- left untouched since this dispatch scoped the task to the one red gate only.",
"tests": "Reproduced RED at 631038b03 (detached checkout inside the existing worktree, tree was clean so nothing was at risk; returned to the branch head afterward with zero diff): node scripts/check-tenant-audit-census.mjs --self-test, exit 1, own verdict text 'an unenforced prose claim reworded off the page IS a finding' / 'check-tenant-audit-census self-test: 1 of 19 case(s) failed.' -- exact match to the diagnosis. Confirmed GREEN at head cacedbd16 (the branch's actual head, now pushed): same command, exit 0, verdict 'check-tenant-audit-census self-test: 19 cases pass (...)'. Also ran the plain invocation CI runs second (lint.yml line 1566): node scripts/check-tenant-audit-census.mjs, exit 0, verdict 'check-tenant-audit-census: OK -- 219 write call sites certified (147 decidable; 9 tenancy-enabled sites PROVABLY carry no tenant context, 32 more unreadable), 23 prose figures held to the census.' Both commands match lint.yml lines 1565-1566 verbatim. Pushed: git push -u origin claude/issue-14787-user-writable-locale, 631038b03..cacedbd16, fast-forward. Re-derived the gate family with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands after git fetch origin main (tool flagged the local tree as behind origin/main with two of its own source files changed across that range -- noted, not resolved by merging main since that was out of this narrow task's scope). Ran the 21 direct-node/pnpm gates that dispatch-gates.mjs matches specifically against the two files this fix touched (content/docs/permissions/tenant-audit-census.mdx, docs/audits/2026-08-tenant-audit-write-call-sites.counts.md), each command captured via redirect-then-read (never across a pipe) and read by its own verdict text: node scripts/check-doc-frontmatter.mjs, check-doc-route-spelling.mjs, check-docs-section-name.mjs, check-section-landing-index.mjs, check-cross-package-test-inputs.mjs, check-ci-filter-parity.mjs, check-shard-attestation.mjs, check-nul-bytes.mjs, and pnpm check:corpus-claim-drift / doc-anchors / doc-authoring / docs-audit-scope / docs-redirects / docs-single-h1 / merge-driver / published-readme-links / react-page-adapter-contract / role-word / skill-identifier-liveness / vendor-version-stamps -- all 21 exit 0 and green. One gate, node scripts/check-test-completeness.mjs, returned exit 3, own verdict: 'PREREQUISITE NOT MET -- this gate grades a saved turbo run test log, and no log was named ... the local reading for this gate is NOT MEASURED ... It is NOT a finding' -- recorded as NOT MEASURED, neither green nor red. Post-push, PR 14958 stayed draft (confirmed via pull_request_read) and Lint & Repo Gates is in_progress on the new head per GitHub's own check-runs listing -- reported honestly rather than awaited, per the drive-to-green rule that CI convergence is the PM's to watch.",
"mcp_calls": "5",
"open_questions": [],
"out_of_scope_findings": []
}Generated by Claude Code |
`@objectstack/service-messaging` now exports `LOCALE_TAG_SHAPE`, a new public symbol. Per the director's contract review (631038b, comment 5527214281): "by the same mechanical floor that makes a new export Clause-2 yes, the level is minor." Declared `patch`; corrected to `minor`. No other change -- this is exactly the "(b) the service-messaging changeset level" half of the envelope the same review authorized alongside the census prose fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
|
os-dev-report {
"issue": 14787,
"status": "done",
"branch": "claude/issue-14787-user-writable-locale",
"pr": "https://github.com/objectstack-ai/objectstack/pull/14958",
"premise_still_valid": true,
"summary": "Follow-up authorized by the coordinator: bumped the service-messaging changeset entry from patch to minor, per the director's contract-review advisory (5527214281) -- LOCALE_TAG_SHAPE is a new exported symbol, and the same mechanical floor that makes Clause-2 yes for a new export makes the changeset level minor. Touched exactly one line -- the frontmatter value in .changeset/sys-user-locale-user-writable.md -- nothing under packages/**, none of the three coupled edits, no test, no census page. Committed separately (9288c301d, on top of cacedbd16) so the census-prose commit stays a clean standalone unit. Re-derived the gate family after the commit and ran every changeset-reading gate it names: check-adr-0087-registration.mjs, check-changeset-no-major.mjs, check-empty-changeset.mjs, check:changeset-gate-self-tests, check:objectui-changeset (check:changeset-fixed is not a real derived command -- confirmed absent from the derived list, so it was not run). All five green. Pushed. PR remains draft, needs:contract-review label untouched, draft/ready state left for the coordinator as instructed.",
"tests": "git diff on the commit is exactly one line: `\"@objectstack/service-messaging\": patch` to `\"@objectstack/service-messaging\": minor`; confirmed via git diff before committing. Re-derived the gate family with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands on the new head (9288c301d): exit 0. Ran all five changeset-reading gates it names, each captured via redirect-then-read (never across a pipe), verdict text quoted -- node scripts/check-adr-0087-registration.mjs: exit 0, 'check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).'; node scripts/check-changeset-no-major.mjs: exit 0, 'This diff introduces no major bump.'; node scripts/check-empty-changeset.mjs: exit 0, 'No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).'; pnpm check:changeset-gate-self-tests: exit 0, all three self-tests pass (check-empty-changeset --self-test 118 assertions, check-adr-0087-registration --self-test 292 assertions, check-changeset-no-major --self-test 116 assertions); pnpm check:objectui-changeset: exit 0, 'objectui-range --self-test: all checks passed'. check:changeset-fixed confirmed absent from the derived --commands list (grepped, no match), so not a command to run here. Pushed: git push origin claude/issue-14787-user-writable-locale, cacedbd16..9288c301d, fast-forward. Post-push pull_request_read confirms draft: true, head sha 9288c301d, labels unchanged (needs:contract-review still hung).",
"mcp_calls": "9",
"open_questions": [],
"out_of_scope_findings": []
}Generated by Claude Code |
|
Landing, director seat (session_01WXyGTWPbbreqXow7Z2pZCk), head The PASS in comment 5527214281 was held only on this PR's own CI. Delta between the reviewed head and
Generated by Claude Code |
…cript path `dispatch-gates` keyed a discovered family on the script PATH, so CI's two invocations of one script collapsed into a single entry — and the entry kept was the plain one, because the direct matcher captured the path and dropped the argument tail. Measured on PR #14958: `lint.yml` runs `node scripts/check-tenant-audit-census.mjs --self-test` beside the plain run, the red was carried entirely by the `--self-test` invocation, and the derived list named only the invocation that was already green. A dev following the list verbatim could not see the failure mode at all. The key is now (script, args). The argv half is admitted only when this tool can render the invocation runnably — a complete run of flag-shaped tokens; a tail carrying a value, or continued onto the next line, keeps the bare path key it has today rather than being truncated into a command that cannot run. Read from this tree's workflow text: 28 scripts in `lint.yml` are invoked more than once under different argv, 41 across all workflow files, and every one of them carried a `check-` basename and so collapsed. 204 discovered families become 242; three `check-` scripts CI never invokes plainly lose a bare key that named a command nobody runs. Also: a `--self-test` invocation is never CI-measured-only. The payload-access read that classification rests on is taken from the gate's work body, which a self-test run does not execute, so suppressing it from `--commands` would hide a command a dev can run. And the usage line printed on the derivation-failure path moves `--residue` inside the alternation, so it no longer advertises the `--tier --residue` pair the CLI has refused since #14753. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox
Fixes #14787
Implements maintainer ruling B on the decision card (comment 5522882528, adopted verbatim 「同意」): the identity table's user-writable set grows from two fields to three.
The ruling, and the three edits that carry it
The three edits are inert one at a time, which is why they are one PR:
SYS_USER_PROFILE_EDIT_FIELDS={name, image, locale}plugin-auth/src/sys-user-writable-fields.tslocale, and a locale-only PATCH throwsMANAGED_EXTENSION_EDITABLE_FIELDS.sys_user={locale}plugin-auth/src/managed-extension-fields.tsreadonlyremoved from the columnplatform-objects/src/identity/sys-user.object.tsstripReadonlyFieldsdeletes the caller's value on the update path before the guard or the validator sees it — the whitelist entry alone is a silent no-opSYS_USER_IMPORT_UPDATE_FIELDSinherits the widening by construction (it is a spread of the profile set, not a second list), which ADR-0092 D3 is explicit about.The prerequisite, re-measured rather than assumed
The ruling sequenced this after PR #14775 (#13881) had merged, and the card's triage note recorded
git grep 13881 -- packages/at 0 — a measurement that predates the merge. Re-measured on currentorigin/main:sys_user.localeis present inpackages/platform-objects/src/identity/sys-user.object.tswithreadonly: true,maxLength: 35, Profile group, andMANAGED_EXTENSION_FIELDS.sys_useralready declares it. The premise holds; the stale note does not.The safety property was NOT already enforced — this PR adds it
LOCALE_TAG_SHAPE(the constant the ruling names) lives inservice-messaging/src/recipient-locale.tsand is a read-side normalizer: it silently drops a malformed value at delivery time and falls back. Nothing on the write path checked the shape at all —readonlywas a strip,maxLengthbounds length, and the field schema'sformatkey is authoring metadata that no write path reads. So "a malformed value is refused loudly" was a property no code held on the day it was ruled.It is enforced here by an object-level
formatvalidation rule on the column:objectql's rule validator runs
validationson insert, by-id update and bulk update, so there is no write shape that reaches the column without passing it. A violation returns the standardVALIDATION_FAILEDenvelope with a per-field{ field: "locale", code: "invalid_format" }entry, mapped to HTTP 400 bymapDataError. The message is translated in all four shipped locales, socheck:i18n-coveragestays at 0 untranslated forplatform-objects.Two things the check deliberately does not do, both pinned so a later reader does not "fix" them:
Japaneseis eight letters and is therefore a legal primary subtag — accepted, and falls to the delivery ladder's floor rather than dead-lettering. That is the property [Decision] Per-user notification locale —— 2026-08-13 裁决所等的「实测拉力」已到(hotcrm:4 个已发布语言 × 16 个 notify 节点 × 0 可本地化) #13881's per-recipient chain was built to hold; a closed vocabulary is not something the platform has.nulland''pass, because clearing the column is how a user returns to the deployment default, which the ruling preserves.What did not widen, and why that is the answer rather than a deferral
ADR-0092 D6's session-snapshot mirror keeps
{name, image}. The old code derived it from the update whitelist, which was correct only while the two coincided; its own comment said "widening the whitelist must extend this mapping deliberately". Deliberately, then: better-auth carries nolocaleon its user model and it is intentionally not anadditionalFieldsentry (declaring it there would makegetSessionSELECT a column an environment that has not run schema-sync does not have). There is no cached copy to keep coherent, and merging one in would manufacture an incoherence — auser.localekey present only on cached sessions, only after a profile edit. The mirror set is now a named constant beside the whitelist rather than derived from it.Reported rather than acted on, because closing it is a second security-boundary decision the ruling did not take. After these three edits:
localeon a user-context update tosys_user;member_defaultstill declaresallowEdit: falseonsys_user(plugin-security/src/objects/default-permission-sets.ts, viadenyWritesOnManagedObjects()), so an ordinary member's PATCH to their own row is refused by the permission layer before the guard is consulted;/update-user— what the existingupdate_my_profileaction posts to — cannot carry the field, sincelocaleis not a better-authadditionalFieldsentry.So today a platform admin can set any user's locale through the standard edit form, and a rank-and-file member cannot set their own. ADR-0092 D5 is explicit that this split is intended ("the guard decides which columns any permitted actor may touch"; permission sets decide who), so this PR is faithful to the ruling as written. Granting the member route would follow the
sys_api_keyshape — an explicitmember_defaultentry plus a_selfrow scope for writes — and would also openname/imageself-editing through the data path, which D5 currently routes through better-auth. That is a decision, not an implementation detail.ADR-0092 is not edited here — deliberately
ADR-0092 D1's tier table records Tier 1 as
{name, image}, and this PR makes the shipped set{name, image, locale}. Under Prime Directive #13 that reversal wants an amended ADR — and under Prime Directive #14docs/adr/**is a governed surface, so including it here would fork the whole PR into governance territory rather than contract review. Filed separately as #14951 so the ADR amendment lands as its own governed PR. Flagging it here rather than leaving it for the next reader to discover: ADR-0092's own scope note anticipates whitelist registrations as ordinary work "citing this ADR", but the sys_user tier table is D1's decision text, not an example.The composing pair — this is the WRITE, #14788 is the READ
#14788's endpoint precedence (
sys_user.localewhen set →Accept-Language→ deployment default, ruled D in the same batch) is the reading side of this write. The two were ruled together on purpose: a value the user chose is stronger evidence of intent than theAccept-Languagethe browser just sent, so the column has to outrank the header — which is only meaningful once the user can actually set the column. Neither half is worth much alone.Clause-② — re-derived from this diff, and it agrees with the ruling
yes. A published write path accepts a field it refused before: a user-contextPATCH /api/v1/data/sys_user/:idcarryinglocalewas answered with403 PERMISSION_DENIED(or had the key stripped) onmainand now commits. Theneeds:contract-reviewcarrier is hung on the card and on this PR by the dispatching seat. My measurement and the ruling's agree; the PR stays a draft and this seat does not undraft it or arm auto-merge on CI colour.Pins
The flipped pins are reversals with the reversal named, never deletions:
identity-write-guard.test.ts—getManagedUpdateWhitelist('sys_user')now pins{name, image, locale}; the error text pin moves toEditable fields: name, image, locale.managed-extension-fields.test.ts—localemoves out of the "declared but NOT generically editable" list into its own positive pin, with the sentence it used to carry ("stays off the editable map until a ruling widens the ADR-0092 D2 profile whitelist") quoted as the thing that came true.New pins, in the three directions the deliverable asks for:
locale— it survives the guard untouched;locale. This is the pin that catches a widening that widened too far, and it is asserted on the whole set literal rather than byhas()probes, because a membership probe cannot see a fourth name arrive;SysUserschema, asserting the envelope (code,name, per-fieldcode) rather than that something threw;locale— including when a locale change rides along with a mirrored one;sys_user—auth-plugin.tsskips that object in the registration loop, so nothing else holds those two lists together;recipient-locale-shape-parity.test.tsasserts byte equality plus behavioural agreement over a corpus, and pins the one intended asymmetry ("null"is shape-legal on both sides; only the read side's stringified-nothing list refuses it).Verification
All figures from
git rev-parse --short HEAD=631038b03.pnpm --filter @objectstack/plugin-auth exec vitest run src/identity-write-guard.test.ts src/managed-extension-fields.test.ts src/sys-user-locale-write-contract.test.ts— 3 files, 70 passedpnpm --filter @objectstack/service-messaging test— 33 files, 364 passed (includes the new parity pin)pnpm --filter @objectstack/platform-objects test— 33 files, 518 passedpnpm --filter @objectstack/plugin-auth --filter @objectstack/platform-objects --filter @objectstack/service-messaging typecheck— all three Done; plugin-auth's test layer also passescheck:test-typecheckagainst its shrink-only ledger (10 files / 94 errors / 23 pinned signatures held, unchanged by this diff)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, re-derived after every commit that changed the file set (the changeset and the translation bundles each added families), and the union re-run on the final head. All exit codes captured by redirect-then-read, never across a pipe.check:i18n—platform-objects: 7 bundle(s) drifted from the schema, because the new rule'smessageis a translatable leaf (objects.sys_user._validations.locale_bcp47_shape.message). Regenerated withnode scripts/check-i18n-bundles.mjs --write, then the three non-enleaves were hand-translated and their fill-provenance entries dropped from the source-hash bundles, which is what keeps the coverage ratchet at zero rather than at one. Nowcheck-i18n-bundles: OK (9 package(s) — all bundles in sync, no undeclared authoring keys)andcheck-i18n-coverage: OK (13 config(s), 691 baselined untranslated string(s), none new).check-test-completeness— "PREREQUISITE NOT MET — this gate grades a savedturbo run testlog, and no log was named […] the local reading for this gate is NOT MEASURED".check:i18n,check:i18n-coverageandcheck:dual-build-cjs-loadseach reported exit 3 first for a missing build prerequisite; all three had that prerequisite cleared and were then run for real, so they are measured rather than left at 3.check:dual-build-cjs-loadsthen went red once on the final head with four findings against@objectstack/metadata("the require condition declares types ./dist/index.d.cts, which is NOT emitted though packages/metadata/dist exists") — a package this diff does not touch, whosedist/in this container held a declaration-less build.pnpm --filter @objectstack/metadata buildemitted 8/8 declarations and the gate returned to OK, so that reading was local build state and not a finding about this change. Recorded rather than quietly dropped, because a red that is explained away deserves the evidence that explains it.check-system-context-censusreddened on pure line rot from this diff's own insertions and was repaired withpnpm check:system-context-census --fix(two anchors:identity-write-guard.ts:98to:99,auth-plugin.ts:1405to:1412). No prose was touched; againstorigin/mainthis page's diff is those two line numbers and nothing else.Ablation
Direction predicted before running: RED in two packages, with the well-formed cases staying green.
Mutation:
SYS_USER_LOCALE_TAG_PATTERNreplaced with a pattern that accepts everything, carrying a unique marker. Both suites resolve@objectstack/platform-objectsthrough itsexportstodist/, so every leg rebuilds and proves the bytes landed.775b0aaeto3835c294;dist/:ablation-dist-preflight— marker present in 6 built files;git checkout HEAD --on the absolute path, blob back to775b0aae(equal to the HEAD blob),git diff HEAD0 bytes, marker count 0;dist/:ablation-dist-preflight --absent— marker absent from all 66 built files, working tree clean against HEAD;Follow-ups filed
sys_user.localeobjectui#7501 — the "my language" form item, carryingBlocked-by: objectstack-ai/objectstack#14787and a warning that the member write route is not settled (see the measured gap above). Duplicate-checked against objectui with a positive control that returned 14 hits on the same channel, so the zero is a live reading.{name, image}— the 2026-09-03 ruling made the shipped set{name, image, locale}#14951 — amend ADR-0092 D1's tier table to record the widened Tier 1.🤖 Generated with Claude Code
https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code