Skip to content

fix(cloud-connection): refuse install-local sample data for a session with no active organization (ADR-0123 D2/D4) - #21780

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21774-install-local-no-active-org
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21774-install-local-no-active-org

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21774

Clause-②: no

Under an organization wall, a session with no active organization is ADR-0123 D1's declared state. The install-local sample-data doors now answer it the way ADR-0123 D2 / D4 require, instead of skipping quietly.

What changed

  • resolveActiveOrgId (packages/cloud-connection/src/marketplace-install-local-plugin.ts): the "first membership" fallback is deleted, not repaired. It read sys_organization_member, an object nothing defines.
  • Reseed and purge answer 403 PERMISSION_DENIED (standard catalog, no new code) with one sentence built in one place (noActiveOrganizationRefusal). The sentence says the session has no active organization and gives the remedy.
  • Install still registers the env-wide package. Its seeded block is { mode: "refused", reason }, with the same sentence in reason (was { mode: "skipped", reason: "multi-tenant-no-active-org" }).
  • RESEED_SKIPPED stays for reseed's other declines: no-datasets, objectql-or-metadata-missing, seed-error: ….
  • storage-service-plugin.ts (comment only, declared cross-lane): the clause citing the deleted fallback now cites ADR-0123 D1. The comment's own rule is unchanged.
  • Ratchets moved down by one each for this file (scripts/slot-lookup-baseline.json 15 to 14, scripts/query-options-erasure-baseline.json 2 to 1). The deleted fallback carried those sites.

Measured before the change (origin/main ebfe658c72, real walled boot, posture-only)

Tenancy isolated, isolationActive: true; session activeOrganizationId null; 1 sys_member row for the admin.

  • install: 200, seeded {mode: "skipped", reason: "multi-tenant-no-active-org"}
  • reseed: 400 RESEED_SKIPPED "Reseed did not run: multi-tenant-no-active-org"
  • purge: 400 RESEED_SKIPPED "Purge did not run: multi-tenant-no-active-org. …"
  • the fallback's read: Object 'sys_organization_member' not found (thrown, swallowed)

Pins

  • Unit: marketplace-install-local-no-active-organization.test.ts (new). It covers walled with no active org and two memberships served by the store, walled with an active org, and single (a spy proves resolveActiveOrgId is never called). The purge unit pin moves to 403.
  • Door pin on a real walled boot: packages/qa/dogfood/test/install-local-no-active-organization.dogfood.test.ts (new). In one session: no-org install/reseed/purge, then the same session after set-active to org B, as the control. 7/7 green.
  • Reverse verification (A5), committed fix at a1a5513852, through scripts/ablation-replace.mjs, two literal anchors, nested WRAP:
    • anchor 1, the seed refusal line, replaced by the old mode: 'skipped' line: blob 5f88579b38e0 to 24f914c847b4
    • anchor 2, the purge 403, replaced by the old RESEED_SKIPPED / 400: blob to 505004807245
    • On disk: removed lines 0/0, injected lines 1/1.
    • Unit: 4 red (the 3 no-org pins and the purge no-org pin; received skipped and 400), 33 green.
    • Door: 3 red (no-org install/reseed/purge), 4 green (preconditions, nothing seeded, org-B control).
    • Restore: three legs prove blob == HEAD 5f88579b38e0 and an empty git diff HEAD.

Verification (head 0590b46d38)

  • pnpm --filter @objectstack/cloud-connection test: 38 files, 466 tests passed.
  • typecheck green for cloud-connection, dogfood and service-storage; --listFiles includes both new test files.
  • full pnpm lint: exit 0.
  • dispatch-gates --commands derived 79 commands; 78 ran green on the final head. check-empty-changeset is red on purpose; see below.

Pending release note corrected (the empty-changeset gate stays red by design)

.changeset/21728-install-local-purge.md is pending, from #21773; the last version PR 617f25f8a4 predates that merge. It said a no-active-organization purge is answered 400 RESEED_SKIPPED. This PR makes that sentence false, and both changesets ship in one release, so its clause now reads 403 PERMISSION_DENIED, naming the missing active organization. Nothing else in that note changed. The gate's own text classes this as a deliberate correction that a person confirms on the PR. Please confirm or reject this edit.

Acceptance notes

  • The console (objectui at the pinned .objectui-sha, marketplaceApi.ts postLocalSampleAction) shows error.message for any non-ok status and does not branch on the code or status. The 400 to 403 move is shown to the user as the new sentence. The os package install CLI does not read seeded.
  • resolveActiveOrgId reads only the better-auth session. An API-key caller (no session cookie) on a walled boot is refused with the session wording. Before, it was skipped. This was not measured further; carrier: none.

Generated by Claude Code

claude added 4 commits October 4, 2026 23:15
… with no active organization

Under an organization wall, a session with no active organization is
ADR-0123 D1's declared state. The install-local resolver no longer guesses
an organization from the user's memberships (its fallback read an object
nothing defines, so it threw and was swallowed). Reseed and purge answer
ADR-0123 D2 / D4's 403 PERMISSION_DENIED naming the missing active
organization, and the install's `seeded` block reports `mode: 'refused'`
with the same sentence in `reason`. The storage plugin's comment that cited
the deleted fallback now cites ADR-0123 D1.

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com>
…a walled boot

A walled showcase boot whose session has no active organization, its user
a member of two organizations: install reports `seeded {mode: 'refused'}`
naming the missing organization, reseed and purge answer 403
PERMISSION_DENIED naming it, and no seed row lands anywhere. The same
session with organization B active reseeds, purges and reinstalls in B.
Adds the patch changeset.

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com>
…cal fallback

The deleted membership fallback carried one untyped `objectql` slot lookup
and one `as any` query bag; both ratchets fell by one for
`marketplace-install-local-plugin.ts`.

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com>
The pending purge changeset said a session with no active organization
is answered 400 RESEED_SKIPPED; this change makes it 403
PERMISSION_DENIED naming the organization, and both changesets ship in
the same release. The new changeset no longer says the purge answered
400 before: that answer never shipped.

Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cloud-connection, @objectstack/service-storage, touching 12 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/services/service-storage/src/storage-service-plugin.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

24 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json ebfe658c7241076064d08a08d0c84e468d4ee3c7.

⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/services/service-storage/src/storage-service-plugin.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ebfe658c7241076064d08a08d0c84e468d4ee3c7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2037925d57e973c018f4651fdfae7f86c2ebb06b — the merge of head 0590b46d38d5e942324b59a1f3361c98b9978683 into base ebfe658c7241076064d08a08d0c84e468d4ee3c7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2037925d57e973c018f4651fdfae7f86c2ebb06b && git checkout 2037925d57e973c018f4651fdfae7f86c2ebb06b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ebfe658c7241076064d08a08d0c84e468d4ee3c7 0590b46d38d5e942324b59a1f3361c98b9978683 && git checkout -B drift-repro ebfe658c7241076064d08a08d0c84e468d4ee3c7 && git merge --no-ff 0590b46d38d5e942324b59a1f3361c98b9978683

node scripts/docs-audit/affected-docs.mjs --json ebfe658c7241076064d08a08d0c84e468d4ee3c7

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

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0590b46d38d5e942324b59a1f3361c98b9978683
Local-runs: none

Inputs read: card #21774 (body + all 5 comments: triage 5984276000, claim 5985374476, os-dev report 5985886480, ACCEPT 5985901016, handover 5985929630); PR #21780 (body, 9-file list, net diff against base ebfe658c7241076064d08a08d0c84e468d4ee3c7, which is origin/main as fetched); the 35 check-runs on the head; base files via git show origin/main:PATH; ADR-0123 at docs/adr/0123-no-active-organization-session-semantics.md. The PR is a draft on claude/issue-21774-install-local-no-active-org, no skip-changeset label.

Check-runs on this head (latest per name; every name appears exactly once, all on 0590b46d38): 31 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in) — conclusion skipped, not failure), 1 failure: Check Changeset. That is the ONLY non-green check. Green includes Lint & Repo Gates, all four Type Check jobs, Test Core (6/6 shards + rollup), Dogfood Regression Gate (3/3 + rollup), Dogfood Verify CLI, Temporal Conformance, Spec property liveness, Governed Surface Queue Guard, the single-writer-path and same-issue claim guards, and the branch-claim guard.

① Derived judgments

Accept-set and public-surface changes the diff implies, each judged:

  1. resolveActiveOrgId membership fallback deleted, not repaired (marketplace-install-local-plugin.ts, base :2203–:2213 removed). Verified on origin/main: git grep sys_organization_member finds exactly the one call site and the storage-service-plugin.ts:996 comment; the defined membership object is sys_member (packages/platform-objects/src/identity/sys-member.object.ts). The read threw and was swallowed, so no resolved behaviour is lost. The resolver now answers session.activeOrganizationId or null. Right — ADR-0123 D1 (the state is declared; no fourth semantics) and the triage ruling's ⛔ on repairing it to sys_member.

  2. Reseed, walled, no active organization: 403 PERMISSION_DENIED with noActiveOrganizationRefusal('reseed'), via applySideEffects(..., door: 'reseed') returning mode: 'refused' and a new branch ahead of the skipped branch. Was 400 RESEED_SKIPPED "Reseed did not run: multi-tenant-no-active-org" (base :1631–:1637). Right — ADR-0123 D2 (catalog code; PERMISSION_DENIED is in the standard catalog, packages/spec/src/api/error-code-ledger.test.ts:285, so ADR-0112 D3 needs no new registration) and D4 (the sentence contains "this session has no active organization" and the remedy). The skipped branch still serves no-datasets, objectql-or-metadata-missing and seed-error: … as 400 RESEED_SKIPPED; the RESEED_SKIPPED ledger row (error-code-ledger.zod.ts:1028, "reseed declined to run; message carries why") keeps live emitters and stays true. Right.

  3. Purge, walled, no active organization: 403 PERMISSION_DENIED with noActiveOrganizationRefusal('purge') (diff :1813–:1818); was 400 RESEED_SKIPPED (base :1759–:1768). ADR-0123 D2's delete clause ("a delete under this state matches no row and is governed by the read rule") does not reach this door: purgeSeedRows reads under READ_CONTEXT = { isSystem: true } and deletes under SEED_WRITE_EXECUTION_CONTEXT (marketplace-install-local-purge.ts:86, :239), i.e. past Layer 0, with the scope supplied by this handler — "match no row" is not available without the resolver guessing a scope, which D1 forbids; the base already refused (400); the triage ruling names purge as a tenant-scoped write to refuse. Right.

  4. Install, walled, no active organization: still 200 success, package registered env-wide, seeded: { mode: 'refused', reason } with the install sentence (package installed; join or select an organization, then reseed). Was { mode: 'skipped', reason: 'multi-tenant-no-active-org' }. withSampleData stays unset (the mode === 'inline' && rows > 0 guard at base :1157 is untouched). Right per the triage direction. Public-surface reading of the new value 'refused': it is a host response, not a published schema — packages/spec/src has no install-local response schema (app-install.zod.ts seededRecords is a different system shape); the return type sits on a private method; no consumer reads seeded.mode (objectui at pinned .objectui-sha 2e818d0b51: no seeded reader under packages/app-shell/src/console/marketplace; packages/cli/src: none). Not a Clause-② event. Right.

  5. noActiveOrganizationRefusal + NO_ACTIVE_ORGANIZATION_CODE/STATUS: module-private, one sentence for three doors, not exported. D4 satisfied in one place. Right.

  6. Wire-visible 400 to 403 on reseed and purge: a refusal replacing a refusal; no accept set moves. The console consumer (marketplaceApi.ts:802–:817 at 2e818d0b51, postLocalSampleAction) throws error.message with .code for any non-ok status and branches on neither status nor code — verified, so the user sees the new sentence. The purge's 400 was never released (see ②). Right.

  7. single posture and walled-with-active-org paths unchanged: organizationWallActive false means the resolver is never called (unit pin spies on it); with an org, all three doors act in it (unit + door control in org B). Right; the claim fence "no change to the unwalled path" holds.

  8. storage-service-plugin.ts: a 4-line comment edit only (base :995–:998); the clause citing the deleted fallback now cites ADR-0123 D1 and the rule ("no active organization means no stamp") is unchanged. Cross-lane declared in the claim (5985374476, seat post [PM seat] domain:services · seat 2 — ⏳ vacant #21118) — that post is outside this brief's inputs and is recorded as declared, not re-read; the single-writer-path guard on the head is green. Right.

  9. Ratchets scripts/slot-lookup-baseline.json 15 to 14 and scripts/query-options-erasure-baseline.json 2 to 1 for the plugin file: the deleted fallback carried exactly one const ql: any = ctx.getService('objectql') (the SLOT_LOOKUP_ANY_MESSAGE shape) and one ql.find(..., { … } as any) (the query-options/no-any-erasure shape); the added code carries neither. Both checkers are exact-count (a decrease without --update fails), both run in Lint & Repo Gates (lint.yml:438–:461), which is green. Right.

  10. Pins: new unit file (3 no-org pins, 3 active-org controls, 1 single spy pin; memberships served under both object spellings so a guessing resolver goes red); purge unit pin moved to 403; new dogfood file lands in the isolated project (include: ['test/**/*.test.ts'] minus SHARED_SHOWCASE, packages/qa/dogfood/vitest.config.ts:280–:282) and ran under the green Dogfood Regression Gate. The A5 ablation is the dev's own measurement, consistent with the two anchors in the diff; not re-run here. Right.

  11. Claim fences: no new error code; no packages/spec path in the 9 files; service-storage comment-only; ledger entry shape (withSampleData, sampleDataPurged) unchanged. All held. No content/docs page states the old 400 answer or multi-tenant-no-active-org (grep on main: only the generated ledger listings of RESEED_SKIPPED, still true), so no doc edit was owed; the Docs Drift comment is advisory.

No accept set is widened or narrowed anywhere in the diff.

② Semver level

  • .changeset/21774-install-local-no-active-org.md (new): "@objectstack/cloud-connection": patch. @objectstack/cloud-connection is a released, non-private package (17.6.0, public exports); a bug fix in a released package takes patch, never skip-changeset. Right. The PR body and the changeset both declare Clause-②: no with no arm — well-formed, and matched by the diff: no schema accepts more, no export or code is added, refusals keep refusing. Right. Sentence by sentence against the head: (a) reseed and purge "answer 403 PERMISSION_DENIED, with a message saying the session has no active organization and that one must be joined or selected" — TRUE (noActiveOrganizationRefusal text + the two 403 returns); (b) "Before, the reseed answered 400 RESEED_SKIPPED (multi-tenant-no-active-org)" — TRUE of the base; (c) "the purge, which starts deleting in this same release, refuses the same way from the start" — TRUE: the purge's deleting landed in fix(cloud-connection): install-local purge deletes seed rows through the engine by their seed key #21773 (d7fff217, 2026-10-04) after the last version PR (617f25f8, 2026-10-02, an ancestor of it), so 21728 is pending and both notes ship together; (d) "Reseed's other declines are unchanged and still answer 400 RESEED_SKIPPED: … no seed datasets, … no data engine or metadata service, and a seed run that threw" — TRUE (no-datasets, objectql-or-metadata-missing, seed-error: stay skipped); (e) install "still installs the package … seeded block now reads { mode: "refused", reason: "…" } … says to select one and then reseed. Before, it read { mode: "skipped", reason: "multi-tenant-no-active-org" }" — TRUE; (f) "No organization is guessed … The fallback read an object no package defines, so it never resolved anything" — TRUE (grep above); (g) "Unchanged. … Without a wall (single posture), no organization is read, and the three doors act table-wide" — TRUE.

  • .changeset/21728-install-local-purge.md — the DELIBERATE CORRECTION, named by path. Present on the merge base (added by fix(cloud-connection): install-local purge deletes seed rows through the engine by their seed key #21773 at d7fff217), pending and unreleased (the only chore: version packages after it does not exist; 617f25f8 predates it). The PR changes one sentence of its Scope. bullet (1 addition, 1 deletion; the rest of the file is byte-identical):

    • OLD: "A session with no active organization is answered 400 RESEED_SKIPPED (multi-tenant-no-active-org), the way reseed answers it." — would be FALSE once this PR ships: at the head the purge answers 403 PERMISSION_DENIED, and the string multi-tenant-no-active-org no longer exists in the plugin. Since both notes compile into one release, keeping it would publish a false sentence beside a true one.
    • NEW: "A session with no active organization is refused with 403 PERMISSION_DENIED and a message naming the missing active organization, the way reseed refuses it." — TRUE of the code at this head: handlePurge returns { code: NO_ACTIVE_ORGANIZATION_CODE, message: noActiveOrganizationRefusal('purge') } with status 403; the message carries "this session has no active organization"; reseed refuses through the same builder, code and status.
    • The surrounding, unchanged sentences of that bullet ("removes only the seed rows of the caller's active organization…"; "Without a wall … the match is table-wide…") and the rest of the note remain true at the head.
    • Class: DELIBERATE CORRECTION, not COLLISION — the edited file is card-scoped 21728-…, the PR adds its own 21774-…, and the edit is one clause. The skip-changeset label is correctly absent (ruling D on [finding] the skip-changeset label suppresses the DELIBERATE-CORRECTION refusal whose own text says 「no label and no diff shape makes that safe」 — declared contract against enforced behaviour #18375: never applied to a PR that edits an existing changeset).
  • The red. Check Changeset fails by the gate's content-blind foreign-changeset rule (scripts/check-empty-changeset.mjs, finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR's minor changeset and every gate stayed green #17712: --diff-filter=MD over .changeset/*.md present at the merge base and not added by this PR); the diff's M .changeset/21728-install-local-purge.md row is that refusal's input. The job's log could not be fetched (the Actions log endpoint is refused by this container's proxy), so the cause is derived from the gate's documented rule and the diff row; the job's other two scripts have nothing to red (patch bump, no with no arm). The workflow's own text classes this red as the point of the class and states Check Changeset is not a required context. Confirmed: a DELIBERATE CORRECTION red, and the only non-green check on the head.

③ Boundary flags

  • open_questions[0] — confirm the correction to pending .changeset/21728-install-local-purge.md? A: keep it. Judged above, sentence by sentence; the seat's answer A (5985901016) is confirmed independently here.
  • Deviation: edit to a pending note — answered as above; the path belongs on this claim's surface, as the ACCEPT recorded.
  • Deviation: two baselines ratcheted down by one — right (① item 9).
  • Deviation: main merge was a no-op — verified: the PR base equals origin/main ebfe658c72.
  • Deviation: new seeded.mode value 'refused' — a host-response value with no published schema, no export, no code, no reader; not a Clause-② event (① item 4).
  • Deviation: a new dogfood file rather than a block in the purge file — right; it boots its own walled fixture and correctly stays out of SHARED_SHOWCASE.
  • A1 (measured-before) and A5 (ablation) — the dev's measurements on a real boot; consistent with the base code I read and the diff's anchors; not re-run under this brief's read-only rule.
  • Out-of-scope note (carrier: none): a walled API-key caller (no better-auth session) is now refused at these doors with the "this session" wording where it was skipped before. Answered, not escalated: the refusal direction is ADR-0123's own (a caller naming no tenant cannot land a tenant-scoped row), the wording is a nit for a principal the dev did not measure and requireInstallCapability decides whether such a caller is admitted at all; not a landing blocker. Filing a carrier card is the seat's call.
  • ADR-0123 D2 delete clause vs. the purge 403 — raised and answered in ① item 3.
  • Cross-lane service-storage comment — declared in the claim; declaration post outside this brief's inputs; single-writer-path guard green.
  • Docs Drift advisory (1 file with no anchor: the comment-only storage-service-plugin.ts) — nothing owed; no doc states the falsified sentence.

Implemented-by: claude/issue-21774-install-local-no-active-org
Reviewed-by: session_01RWZbGvPFcRKvUqASZtunCU

Independence: INDEPENDENT AGENT

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 01:00
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 01:00
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit e09f1ac Oct 5, 2026
36 of 37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21774-install-local-no-active-org branch October 5, 2026 01:34
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ace, meta-spelling and studio test titles state each cited decision in words instead of a tracker number (stage 14) (objectstack-ai#21799)

Part of objectstack-ai#20749
Clause-②: no

Stage 14 of this card, and the fifth area of class (e): the test strings
shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513.
This stage takes the eight small directories together: `security/`,
`ai/`, `identity/`, `integration/`, `migrations/`, `marketplace/`,
`meta-spelling/` and `studio/`. Their 91 test-title and test-string
literals carried 96 tracker ids citing 65 records. 94 ids in 89 literals
now either state what their record decided, in words (form D), or are
dropped where the title already says it. Two ids stay, for the reason
given below. Text only: no assertion, identifier, test count or code
comment changes.

## Census at the base (`e83c9f6154`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`) and
`census-wide.cjs` (md5 `c98410a19529c439adb0afbfb00026a2`),
byte-identical to the copies stages 10 to 13 used. A literal counts as a
test title when its folded message is argument 0 of a `describe` / `it`
/ `test` call, `.each` / `.skip` / `.only` chains included. Everything
else is an "other" string.

The worktree was cut from `origin/main` at `e83c9f6154`, one commit past
the claim's `8256a4b272`. That commit touches only
`api/error-code-ledger.zod.ts`, which is not a test file, so the test
census is the same at both.

Both instruments read **1414 messages / 1500 ids in 315 files**, the
seat's reading at `8256a4b272`. That is one more than stage 13's head
reading (1413 / 1499 at `72513933ee`), and the one id is in
`ui/component-props-unknown-members.pin.test.ts`. It moved from 1 / 1 to
2 / 2 when objectstack-ai#21764 (`4331a6b16c`, 2026-10-04T17:33Z) landed between the
two readings. That commit removed one id-bearing string and added two: a
`ruling:` value at `:322` that the assertion at `:417` matches with a
regular expression on its number, and a `describe` title at `:596`. It
joins the `ui/` stages.

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `data/` | 95 | 468 / 501 | 445 / 475 | 23 / 26 |
| `ui/` | 81 | 393 / 416 | 375 / 398 | 18 / 18 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| **`security/`** (this PR) | 8 | **28 / 28** | 28 / 28 | 0 |
| **`ai/`** (this PR) | 9 | **18 / 20** | 13 / 15 | 5 / 5 |
| **`identity/`** (this PR) | 6 | **15 / 15** | 14 / 14 | 1 / 1 |
| **`integration/`** (this PR) | 4 | **14 / 14** | 13 / 13 | 1 / 1 |
| **`migrations/`** (this PR) | 2 | **9 / 12** | 9 / 12 | 0 |
| **`marketplace/`** (this PR) | 2 | **3 / 3** | 3 / 3 | 0 |
| **`meta-spelling/`** (this PR) | 1 | **2 / 2** | 2 / 2 | 0 |
| **`studio/`** (this PR) | 2 | **2 / 2** | 2 / 2 | 0 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **315** | **1414 / 1500** | **1330 / 1412** | **84 / 88**
|

The eight directories read **91 messages / 96 ids in 34 files**, the
seat's figures.

- **Controls.** Lit, a title with three ids:
`migrations/migrations.test.ts:293`. Lit, an `expect` message:
`identity/api-key-retirement.test.ts:82`. Dark: the comment at
`security/permission.test.ts:309` ("The objectstack-ai#12497 refusal shape was
measured as") reads 0. Planted in a scratch copy of the head
`security/explain.test.ts`: an id put back into a title reads 1 / 1, and
an id put into a comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same totals in
seven of the eight directories. In `studio/` it reads 5 / 5 at the base,
because three literals in two test files are hex colours (`#7c3aed`,
`#2563eb`, `#94a3b8`). None matches the gate's pattern.
- **At the head:** 1325 messages / 1406 ids in 282 files. `ai/` reads 2
/ 2 (the two needles below), and the other seven directories read 0 / 0.
Nothing outside the eight moved. The wider pattern adds only the three
hex colours.

## How the area was chosen

Stage 10's rule ranks whole first-level directories by ids and takes the
busiest within about 10% of the ~100-id bound. `data/` (501), `ui/`
(416), `api/` (201) and `system/` (165) each exceed it alone, and the
files directly in `src/` (120) are 20% over. The eight small directories
read 96 together, within the bound. That is the group the stage-12 and
stage-13 ACCEPTs named, so the rule needed no second pass.

**Named for the next stages** (the head census, 1325 / 1406):
- `data/` 501 in five stages. It has one subdirectory, so the files
directly under it go in name order, in groups near the bound:
1. `aggregate-field-type-compatibility.test.ts` to
`default-value-tokens.test.ts`: 22 files, 109 ids;
2. `document.test.ts` to `filter-dotted-head.test.ts`: 21 files, 109
ids;
3. `filter-empty-operator.test.ts` to `hook-body.test.ts`: 21 files, 108
ids;
  4. `hook.test.ts` to `record-surface.test.ts`: 16 files, 107 ids;
5. `search-fields.test.ts` to `validation.test.ts` (8 files, 16 ids)
with `data/driver/` (7 files, 52 ids): 68 ids.
- `ui/` 416, about four stages.
- `api/` 201, two.
- `system/` 165, two.
- The files directly in `src/`, 120, one.
- The two needles left in `ai/build-progress.test.ts` and the one in
`contracts/approval-service.test.ts`. Each leaves only together with the
source docblock it pins.

## What each id became

29 literals (33 ids) now state a decision in words. 60 literals (61 ids)
drop a number the title already explains. Every cited record was read
with its comments through REST: 60 answer 200. objectstack-ai#6362, objectstack-ai#8715 and objectstack-ai#14676
answer 404, and their decisions were read from the landing commits.
`cloud#1967` and `cloud#2172` answer 403, because the cloud repository
is not attached to this session. `cloud#1967`'s decision was read from
what landed, and `cloud#2172` is one of the two needles that stay.

| record(s) | literal | now reads |
|:--|:--|:--|
| objectstack-ai#16870 | `security/explain.test.ts:413` | "the AUTHORING accept set
refuses a readScope beside viewAllRecords, the pair this snapshot shape
tolerates". The record refuses a depth axis beside the super-user bit
that short-circuits it. |
| objectstack-ai#17189 | `security/high-privilege.test.ts:30` |
"describeHighPrivilegeBits — an app-declared capability is not a
platform system permission". Ruling (i): a name on the stack's declared
capability list does not count as a system permission. |
| objectstack-ai#3391 | `security/permission.test.ts:545` |
"EffectiveObjectPermissionSchema (response side: the server-resolved
operations the UI renders)". The contract: the server resolves each
object's effective operations, and the UI only renders what it is
served. |
| objectstack-ai#6762 | `security/rls.test.ts:704` |
"RowLevelSecurityPolicySchema.using — the published description
advertises what the compiler lowers". The description was corrected to
the subset ADR-0058 widened. |
| objectstack-ai#12699 (2) | `security/tenancy-posture.test.ts:21`, `:50` |
"PlatformGlobalObjectsSchema — the objects a deployment exempts from the
Layer 0 wall" and "OrgScopingEntitlementSchema — the deployment facts
Layer 0 arming reads". |
| objectstack-ai#15813 | `security/tenant-layer0-verdict.test.ts:16` |
"TenantLayer0VerdictSchema — the four verdicts the wall records on an
operation". Ruling (i): `plugin-security` records the Layer 0 verdict it
computed, and the publish site reads it. |
| objectstack-ai#3820, objectstack-ai#3894 | `ai/agent.test.ts:74` | "agent.tools retirement
(ADR-0064) — tombstoned; tools move into skills". `agent.tools[]` was
removed, and the docs teach the action-to-skill path. |
| objectstack-ai#3278 | `ai/knowledge-source.test.ts:97` | An `it.each` row: "a
dialect the protocol does not declare (`js`, a retired expression
dialect, ADR-0058 addendum)". |
| objectstack-ai#7113 (2) | `ai/skill-trigger-condition-value-shape.test.ts:47`,
`:175` | "a set operator carrying a scalar is refused at authoring time"
and "the value-shape refinement does not disturb the carrier". The value
is shaped by its operator at authoring time. |
| objectstack-ai#3896 | `ai/skill.test.ts:195` | "retired `triggerPhrases` — phrases
never routed a skill; triggerConditions do". See the note below the
table. |
| objectstack-ai#8715 | `identity/api-key-retirement.test.ts:82` | A declared `expect`
message: "... must have zero holders after the ApiKeySchema retirement".
The record answers 404. `2c86fe3ea7` retired the fictional
`ApiKeySchema`, so `sys_api_key` has one declaration. |
| objectstack-ai#18509 (2) | `identity/identity.test.ts:88`,
`identity/organization.test.ts:132` | "UserSchema.image accept set —
null, the shape better-auth serves", and the same for
`OrganizationSchema.logo` (landed as `b9d5422142`). |
| objectstack-ai#11965 | `identity/platform-admin-capabilities.test.ts:10` |
"ADMIN_FULL_ACCESS_CAPABILITIES — the one platform-admin list
plugin-security imports". Choice 6A. |
| objectstack-ai#8681 | `identity/platform-admin-capabilities.test.ts:34` | "the
wildcard grants NO export — export stays an opt-in axis, pinned at the
declaration's new home". Direction (a): `allowExport` left the admin
sets' wildcard entry. |
| objectstack-ai#3017 | `integration/connector-provider-errors.test.ts:13` |
"connector provider upstream-unavailable classification — an unreachable
upstream degrades instead of aborting boot". Configuration faults stay
fatal. |
| objectstack-ai#4395 | `integration/connector.test.ts:244` |
"ConnectorActionSchema.effect — declares whether an action reads or
writes". The ruling: an optional read-or-write declaration the run
summary counts. |
| objectstack-ai#6362 | `integration/connector.test.ts:846` | "ADR-0010 protection
envelope — preserved, never silently stripped". The record answers 404.
The decision is read from `b5404f496f`. |
| objectstack-ai#14676 (2) | `integration/connector.test.ts:1163`, `:1195` | A
declared `expect` message, "... after the errorMapping retirement", and
"the errorMapping retirement is registered under ADR-0087". The record
answers 404. The decision is read from `13c48c2a55`, which retired the
eleven `connector.errorMapping` keys. |
| objectstack-ai#4722 | `migrations/migrations.test.ts:258` | "keeps `visible`
client-side only — the half the server-side item gate did NOT change".
The record made the server filter the nav entries inside `areas[]`. |
| objectstack-ai#4651 | `migrations/migrations.test.ts:268` | "still carries the
area-gate removal history the step exists to explain". Ruling B removed
the fail-open area keys. |
| objectstack-ai#5015, objectstack-ai#4610, objectstack-ai#5781 | `migrations/migrations.test.ts:293` |
"protocol-17 NotificationAction / EmbedConfig entry — stops republishing
the falsified zero-consumer claim". |
| objectstack-ai#4610 | `migrations/migrations.test.ts:299` | "finds the entry, and it
still explains the dual-source orphaning (anti-vacuity)". |
| objectstack-ai#5561, objectstack-ai#6844 | `migrations/migrations.test.ts:333` | "protocol-17
resumeAuthority default-flip entry — supportsPause is enforced now, so
stop asking for a hand-audit". |
| objectstack-ai#17594 | `migrations/migrations.test.ts:455` | "protocol-18
element:filter / element:form entry — the chain NAMES the bare node it
leaves standing". |
| objectstack-ai#19056 | `migrations/migrations.test.ts:555` | "every major the floor
move to 16 dropped is refused, by name". The maintainer's ruling raised
the migration support floor from 10 to 16. |

**Dropped only (61 ids):** objectstack-ai#123, objectstack-ai#3544, objectstack-ai#4001 (4), objectstack-ai#4641, objectstack-ai#4703, objectstack-ai#4737,
objectstack-ai#4911, objectstack-ai#5337, objectstack-ai#5481, objectstack-ai#5515 (4), objectstack-ai#5685, objectstack-ai#5955, objectstack-ai#6628, objectstack-ai#6698, objectstack-ai#6861,
objectstack-ai#6919, objectstack-ai#7113 (4), objectstack-ai#7319 (2), objectstack-ai#7990, objectstack-ai#8326 (6), objectstack-ai#8424, objectstack-ai#8715, objectstack-ai#9885,
objectstack-ai#11503, objectstack-ai#12497, objectstack-ai#12840 (2), objectstack-ai#14103, objectstack-ai#14676 (2), objectstack-ai#14825, objectstack-ai#15028, objectstack-ai#15680,
objectstack-ai#15813, objectstack-ai#16870, objectstack-ai#17425, objectstack-ai#17487, objectstack-ai#18728 (4), objectstack-ai#18978, objectstack-ai#20321, objectstack-ai#21260 (2),
`cloud#1967`.

- Each of these titles already states the decision it pins: for example
"the cap is 200: exactly 200 ids parse, 201 are refused (never
truncated)" for objectstack-ai#8326, or "unknown keys are rejected, not stripped" for
objectstack-ai#4001. In `skill-trigger-condition-value-shape.test.ts:130`, "(objectstack-ai#5685: no
stricter than the runtime)" became "— no stricter than the runtime".
- **`objectstack-ai#123`** in `ai/conversation.test.ts:294` (`'Support Chat - Case
objectstack-ai#123'`) is a placeholder that cites no record: objectstack#123 is an
unrelated broken-links report. The string is a fixture's session name,
an input only. No assertion reads it, so dropping the number moves
nothing.
- **`cloud#1967`** in `ai/solution-blueprint.test.ts:674`: the record
answers 403. Its decision is read from `3e3ecb0e8f` and its CHANGELOG
entry: the strict mirror the design model generates against carries the
applier's `SNAKE_CASE` constraints. The title already says "VALUE
parity".
- **`objectstack-ai#3896`** is the sharing-rule card (`POST /data/sharing/rules`
bypassing `SharingRuleSchema`). The skill title cited it as an "audit
close-out", the batch that removed `triggerPhrases` along with other
dead clusters. The title now states the decision that landed with the
key's tombstone (`ai/skill.zod.ts:386`) and its conversion entry
(`conversions/registry.ts:2744`): phrases were never matched, and
activation is `triggerConditions` intersected with the agent's
`skills[]`.
- **The `it.each` rows** at `ai/knowledge-source.test.ts:97` and `:98`
feed a `%s` placeholder. vitest 4.1.11 formats `%s` with `String(value)`
and does not truncate it (`@vitest/utils` `baseFormat`). Only `$name`
interpolation goes through the 40-character `objDisplay`. Both rows are
short anyway, and the name comparison below confirms both full names.

## The two ids that stay

`ai/build-progress.test.ts:236` and `:237` are
`expect(SOURCE).toContain('cloud#2172')` and
`expect(SOURCE).toContain('objectui#7388 block 2')`. They are not
titles. They are the expected values of assertions that read the
`ai/build-progress.zod.ts` docblock and pin that its liveness watch
names its two carriers (`:84-85`). Changing them needs a code comment
and assertion logic, which this claim excludes. They leave together with
that docblock's citations, like the
`contracts/approval-service.test.ts:274` needle.

## Readers

- **Test-name filters:** none. A tracked-tree search for `-t` and
`--testNamePattern` finds only `packages/qa/dogfood/README.md:142` (`-t
"owner-scoped"`), which is unrelated.
- **Snapshots:** none. No `__snapshots__` directory exists under the
eight directories, and no `.snap` file is tracked under `packages/spec`.
- **Projects:** two touched files are listed in
`packages/spec/vitest.repo-tests.json`:
`ai/tool-confirmation-prescription-tense.pin.test.ts` and
`identity/position-delegatable-enforcer.pin.test.ts`. Both were run in
the `repo` project at the base and at the head, and the other 32 in
`local`.
- **By substring:** every old literal, plus a window around each id (263
needles), was searched across the tracked tree outside its own file. No
gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads
one. The hits are:
- **the plan's own siblings:** `unknown keys are rejected, not stripped
(objectstack-ai#4001)` in the four files this PR edits;
- **this card's later stages:** same-text titles in
`data/driver-nosql.test.ts:375`, `data/driver/memory.test.ts:548`,
`data/driver/turso.test.ts:172` and `ui/dashboard.test.ts:717` (`carries
its unit (objectstack-ai#15680)`), `data/object.test.ts:105` (`(objectstack-ai#5955)`) and
`ui/action.test.ts:1612` (`objectstack-ai#3896 close-out`). They are already in the
`data/` and `ui/` census;
- **comments and release text:** the comment at `ai/agent.test.ts:193`,
the `security/sharing.zod.ts:261` docblock, two
`packages/spec/CHANGELOG.md` entries and
`content/docs/releases/v17/17-0.mdx:326`. None reads a test title, and
none is this card's share.
- **Migration tooling and generated files:**
`docs/protocol-upgrade-guide.md`, `packages/spec/spec-changes.json`,
`packages/spec/src/migrations/registry.ts` and the 842 files under
`migrations/entries/`, together with `spec-changes.ts`, `chain.ts`,
`index.ts` and `types.ts`. Searched for every changed literal whole, at
the base and at the head (178 needles), they read 0 hits. The lit
controls `resumeAuthority`,
`ui-notification-action-embed-config-retired` and
`element-filter-and-form-node-refused` hit 5, 4 and 2 files.
`migrations.test.ts` finds each entry by its `id`, never by a title.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. The declared lines are
`ai/conversation.test.ts:294`, `ai/knowledge-source.test.ts:97` and
`:98`, `identity/api-key-retirement.test.ts:82` and
`integration/connector.test.ts:1163`.

- **Result:** 34 of 34 files SAME on all three legs, as predicted in
writing before the run. `ai/build-progress.test.ts` reads SAME with 0
changed.
- **Totals:** 89 changed literals, 84 titles and 5 declared. The diff's
`+` and `-` lines are exactly the 89 planned lines, and every file keeps
its line count.
- **Controls (10 of 10 as predicted, on scratch copies, each anchor hit
once):** identifier rename DIFF; numeric literal DIFF; comment edit
COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten
title given a new id VIOLATION; a title that was id-free at base edited
VIOLATION; one title reverted to base SAME; a declared string keeping an
id VIOLATION; an undeclared `expect` message changed VIOLATION; a title
re-split into a `+` chain DIFF.

**Test counts:** the 34 files were run at the base, in a separate base
worktree at `e83c9f6154`, and at the head, with `--project local
--project repo`. Both sides read 911 / 911 passed, with the same count
and status sequence per file in 34 of 34. 310 full test names change,
and each equals the base name with the planned replacements applied (0
mismatches). No full name repeats on either side.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
34 touched files are in it, and no `*.test.ts` at all. The controls
`src/security/permission.zod.ts`, `src/ai/knowledge-source.zod.ts` and
`dist/security/index.js` are in it.
- In the built `dist/`, five new phrases and four old literals each read
in 0 files. The control `Unrecognized key(s) on` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `b364b8179b`)

- `pnpm turbo run build` over all packages: 71 / 71.
- `@objectstack/spec`:
  - `vitest run --project local`: 615 files, 18358 passed, 1 todo.
- `typecheck` exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 34 touched
files, counted with `tsc --listFilesOnly -p tsconfig.test.json`.
- **Gates:** `dispatch-gates --commands` derived 79 families, the same
set as stage 13, and all 79 exit 0. `--ran` reconciles: 79 derived, 79
run, 0 NOT-MEASURED, 0 UNRUN.
- The five roster families whose rosters sit under a touched directory
were also run, and each exits 0: `check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`
and `check:filter-alias-parity`.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 34 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 34 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 178 changed lines.

## Acceptance notes

- **The two `build-progress` needles** stay with the
`ai/build-progress.zod.ts` docblock they pin. The
`contracts/approval-service.test.ts:274` needle is untouched, as the
claim required.
- **Same-id test titles in other packages** are their lanes' test-string
shares. A search of `describe` / `it` / `test` lines outside
`packages/spec` finds 90 lines citing ids this PR handled, in 16
packages: `plugin-security` 28 (14 files), `rest` 20 (7),
`service-automation` 11 (7), `lint` 5 (4), `plugin-audit` 4 (3),
`runtime` 4 (2), `qa/dogfood` 3 (2), `plugin-hono-server` 3 (1),
`client` 2, `platform-objects` 2, `plugin-sharing` 2, `cli` 2,
`objectql` 1, `connectors` 1, `formula` 1 and `plugin-approvals` 1.
- **Code comments still carry ids** in these files and their sources,
for example `ai/agent.test.ts:193` and `security/sharing.zod.ts:261`.
Comments are not this card's share, and none is touched here.
- **`origin/main` moved** two commits past the base before this PR
opened (objectstack-ai#21780, objectstack-ai#21783). Neither touches `packages/spec` or any file
here, so nothing was merged. The gate reconciliation noted that two
baselines changed across them (`query-options-erasure`, `slot-lookup`).
This diff feeds neither, and the queue re-runs both on the merged
generation.

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ui#11611, objectstack-ai#11614 and objectstack-ai#11619) (objectstack-ai#21800)

Fixes objectstack-ai#21772
Clause-②: no

This moves the bundled Console's objectui pin from `2e818d0b51ec`
(objectstack-ai#21710) to objectui `main` as of the write time,
`9dfaca654311cddd81714153c4f82c241d7cdc54`, which is past `c096f03`. The
Console now carries objectui#11611 (the `ref:dataset` spec-form widget),
objectui#11614 (the grid widget's camelCase keys) and objectui#11619
(the record chrome's picture). Those are the three merges objectstack-ai#21714, objectstack-ai#21768
and objectstack-ai#21765 wait on.

⛔ This is not a release act. Version PR objectstack-ai#21352 is untouched, and no tag,
publish or Release was made. The card's "release first" hold is lifted
by the maintainer's order recorded in the claim:
「浏览器测试已经在运行,还有很多问题在处理,服务端开发不应该被阻塞」.

## Range

- objectui `git ls-remote origin refs/heads/main` read
`9dfaca654311cddd81714153c4f82c241d7cdc54` at 2026-10-05T00:51:29Z, just
before the bump, and the same sha again before this PR was opened.
- `git merge-base --is-ancestor` exits 0 against `9dfaca654311` for all
three carried merges: `b508ac50d9` (objectui#11611), `2abec3a96c`
(objectui#11614) and `c096f03279` (objectui#11619). The objectui clone
is shallow, but exit 0 proves itself there.
- `2e818d0b51ec..9dfaca654311` has 17 commits and 0 merges.
- objectui declared 24 changesets over the range, and all 24 release.
There are 0 release-nothing changesets and 0 commits without a
changeset. None declares `major`. Four carry the author's breaking
annotation, and the highest declared level is `minor`, so the console
changeset is `minor`. These counts come from `scripts/objectui-range.mjs
--from 2e818d0b51ec --to 9dfaca654311 --json` and from the bump's own
digest. They were measured, not copied from the card.
- Four commit subjects carry `!`. `git log --format='%h %s'
2e818d0b5..9dfaca654 | grep -E '^[0-9a-f]+ [a-z]+(\([^)]*\))?!:'` gives
`9db9ff3f9`, `8b14aecbd`, `2abec3a96` and `b403bb36f`.

| objectui commit | landing | changesets | declared disposition |
|---|---|---|---|
| `9dfaca654` | objectui#11615: the default `simple` form draws a
self-describing inline section entry; `ObjectFormSection.fields` gains
the form view's `{ field }` arm | 1, minor, **BREAKING** (for TypeScript
readers) | releasing; declared breaking, answered below |
| `15f67025b` | objectui#11345: the inline grid's default columns derive
through the spec rule `deriveInlineGridColumns` | 1, patch | releasing |
| `4c127cdef` | objectui#11613: `record:related_list` stops requiring
`columns` | 1, minor | releasing; moves the manifest |
| `c096f0327` | objectui#11383 (PR objectui#11619): the record chrome
draws the record's picture from the object's `imageField` | 1, minor |
releasing; smoke below; unlocks objectstack-ai#21765 |
| `6e9090c26` | objectui#11336: an object document's served listViews
count as served views | 1, patch | releasing |
| `9db9ff3f9` `!` | objectui#11266: a form view's `subforms[].columns`
entry is the spec's `InlineGridColumnSchema`, by reference | 1, minor,
**BREAKING** | releasing; declared breaking, answered below |
| `7c9a6b194` | objectui#11340: the docs portal renders the book
resolver's answer | 1, patch | releasing |
| `8b14aecbd` `!` | objectui#11608: `PartialSchema` is retired from
`@object-ui/types` | 1, minor, **BREAKING** | releasing; declared
breaking, answered below |
| `2abec3a96` `!` | objectui#11610 (PR objectui#11614): the grid
widget's eight field-level keys are camelCase; the snake_case spellings
are refused by name | 1, minor, **BREAKING** | releasing; declared
breaking, answered below; unlocks objectstack-ai#21768 |
| `b403bb36f` `!` | objectui#8347: `BaseSchema` loses its index
signature | 1, minor (`Clause-②: yes (narrowing)`, no BREAKING word) |
releasing; declared breaking by its `!`, answered below |
| `fd060f076` | objectui#11605: bound registrations stop requiring
`objectName`, and a node with neither shows a no-object hint | 8, minor
| releasing; moves the manifest |
| `b508ac50d` | objectui#11601 (PR objectui#11611): the `ref:dataset`
spec-form widget, fed by the report inspector's dataset catalog | 1,
minor | releasing; unlocks objectstack-ai#21714 |
| `d2e859936` | objectui#11002: the console asks `GET /usage/storage`
only when the runtime serves `features.storageUsage` | 1, minor |
releasing; smoke below |
| `b92329c89` | objectui#11591: the Organization flows page copy | 1,
patch | releasing |
| `902ebab63` | objectui#11569: `record:line_items` stops requiring
`childObject` | 1, minor | releasing; moves the manifest |
| `278d2444e` | objectui#11546: a node click on a read-only flow canvas
opens the inspector read-only | 1, patch | releasing |
| `b61c116b2` | objectui#11577: `record:details` read mode keeps a
textarea's line breaks | 1, patch | releasing |

## Declared-breaking changes, and how this repo answers each

The ADR-0087 disposition is `not-required (no-migration-prescription)`.
It replaces the bump's `adr-0087: TODO` placeholder in
`.changeset/console-9dfaca654311.md`, and it covers all five entries
below. `check-adr-0087-registration --base origin/main` gives "1
declared-breaking changeset(s), each carrying an ADR-0087 disposition".
`check-changeset-no-major --base origin/main` gives "This diff
introduces no `major` bump".

1. **objectui#11610 / PR objectui#11614 (`2abec3a96`): the grid widget's
eight keys.** `min_rows`, `max_rows`, `allow_add`, `allow_delete`,
`allow_reorder`, `total_field`, `add_label` and `sort_field` become
`minRows` … `sortField`. objectui has no dual read: its zod faces refuse
a snake_case key by name, and its `GridField` draws an inline alert
instead of the grid.
- **This repo, today.** `@objectstack/spec`'s runtime form field
(`buildObjectFormRuntimeField`, `packages/spec/src/ui/component.zod.ts`)
already refuses the eight snake_case keys by name, with guidance that
names objectui#11610. The camelCase keys are not declared there yet.
- **The interim, stated plainly.** Between this PR and objectstack-ai#21768, an
ObjectStack-authored `grid` form field can spell the keys neither way.
Measured on this head's built spec: `object-form` with `customFields: [{
name: 'items', type: 'grid', min_rows: 1 }]` is refused with
`unrecognized_keys` at `customFields.0` and the grid-key guidance. The
same field with `minRows: 1` is refused with `unrecognized_keys` at
`customFields.0`, as an undeclared key. So a `grid` field takes its
`columns` and the widget's own defaults. The refusal's prescription says
these keys "come in once the widget reads a camelCase spelling". At this
pin the widget does, and objectstack-ai#21768 declares the keys and retargets that
prescription.
- **Nothing here carries the retired spelling.** `git grep` over
`examples/` and `packages/` finds no authored snake_case grid key. The
only hits are the spec's own refusal list and its pin test, migration
prose, and one historical note about objectui's internal master-detail
adapter.
2. **objectui#11615 (`9dfaca654`): the `simple` form's inline section
entries.** For TypeScript readers of `ObjectFormSection.fields`, the
type gains the spec's `FormFieldInput` arm. No code in this repo imports
`@object-ui/types`; the only `@object-ui/*` import is
`scripts/gen-sdui-manifest-node.mjs` reading `@object-ui/core` from the
built tree. The behaviour change is that the default `simple` form now
draws an inline `{ name, … }` entry, as the other five form types
already did. The showcase's `object-form` nodes are `wizard`, `drawer`
and `modal` forms whose sections list field names, so none of them draws
differently.
3. **objectui#11266 (`9db9ff3f9`): `subforms[].columns`.** objectui's
validator now judges a column by `@objectstack/spec`'s own
`InlineGridColumnSchema`. This repo has enforced that closed shape since
objectstack-ai#20927. objectui's verdict now matches `os validate`, and the
ObjectStack accept set does not move.
4. **objectui#11608 (`8b14aecbd`): `PartialSchema`.** It leaves
`@object-ui/types`. Nothing in this repo names it (`git grep
PartialSchema`: 0).
5. **objectui#8347 (`b403bb36f`): `BaseSchema` loses its index
signature.** This narrows the TypeScript face of objectui's node types,
and nothing here compiles against them. objectui's zod faces keep their
accept sets for every key except `visibleWhen`. That key widens to the
`{ dialect, source }` envelope this repo's own parse writes.

## The three cards this unblocks

- **objectstack-ai#21714**: objectui#11601's `ref:dataset` widget (PR objectui#11611,
`b508ac50d`) is now in the pinned build, so `report.form.ts`'s
joined-block `dataset` row can declare `widget: 'ref:dataset'`.
- **objectstack-ai#21768**: objectui#11610's camelCase keys (PR objectui#11614,
`2abec3a96`) are now what the pinned widget reads, so the spec's runtime
form field can declare them.
- **objectstack-ai#21765**: objectui#11383's record picture (PR objectui#11619,
`c096f0327`) is now in the pinned build, so `object.imageField`'s
liveness row can move to `live`. The smoke below observes that read in
the browser. The reader is
`packages/components/src/renderers/layout/containers.tsx`, in the
`page:header` record chrome.

## What changed here

- **`.objectui-sha` and `.changeset/console-9dfaca654311.md`.**
`scripts/bump-objectui.sh 9dfaca654311cddd81714153c4f82c241d7cdc54
--no-commit` wrote both, with `OBJECTUI_ROOT` set to the container's
objectui clone after `git fetch origin main`. The range walked
completely without a deepen, and the level was auto-set to `minor`.
- **`sdui.manifest.json` and `scripts/sdui-manifest.record.json`.**
`node scripts/gen-sdui-manifest-node.mjs` regenerated them over the tree
that `pnpm objectui:build` built at the pin. There are 107 components at
both pins, and the sha256 moves from `0ead67c1111d…` to `6f921896ffac…`.
**This time the manifest moves**:
- Eleven inputs lose `required: true` and gain a description. Nine are
`objectName`, on `object-grid`, `list-view`, `object-form`,
`embeddable-form`, `object-master-detail-form`, `object-kanban`,
`object-metric`, `object-chart` and `object-pivot` (objectui#11605). The
other two are `record:related_list` `columns` (objectui#11613) and
`record:line_items` `childObject` (objectui#11569).
- `object-master-detail-form`'s `fields` description is rewritten for
objectui#11615's inline entries.
- Where the spec has a `ComponentPropsMap` row, these inputs are already
optional there. Measured on the built spec: `object-grid`,
`object-form`, `object-kanban`, `object-metric` and
`object-master-detail-form` `objectName`, `record:related_list`
`columns` and `record:line_items` `childObject`. So the manifest now
agrees with the spec rows. The JSX page compile reads the manifest, and
it stops refusing a node whose `dataSource` binding names the object.
- The record moves its pin and `modulesRoot`. objectui's workspace
version stays 17.7.0.
- **`packages/sdui-parser/objectui-lockstep.json`.** `pnpm
gen:sdui-lockstep` re-recorded it against
`OBJECTUI_ROOT=.cache/objectui-9dfaca654311`. It records 214 grammar
lines (blob `0131f27cf86d`), 25 codes and containment predicate
`76c18fb95d1f`. All are unchanged, and objectui's `packages/sdui-parser`
has no diff over the range, so no port is owed.
- **The 54 asserting pin citations in `packages/spec/src`.** They were
re-measured at the new pin, not restamped:
- Each asserting record's anchors were resolved in objectui at
`2e818d0b5`, mapped through `git diff -U0 2e818d0b5 9dfaca654`, and
re-read at the new pin. Each record gains a dated 2026-10-05 hop
sentence and keeps its earlier history.
- In every cited file that changed, the cited lines moved with their
text byte-identical:
- objectui#8347 re-worded docblocks in `ObjectGrid.tsx`,
`ObjectTree.tsx`, `ObjectGantt.tsx`, `ObjectCalendar.tsx`,
`SchemaRenderer.tsx`, `plugin-view/src/ObjectView.tsx`,
`plugin-map/src/index.tsx` and `plugin-gantt/src/index.tsx`.
- In `ObjectKanban.tsx`, objectui#8347 added a private
`GateBoundKanbanSchema` read type, so its fetch, navigation reads and
spread moved +30.
- objectui#11605 touched `plugin-kanban/src/index.tsx`,
`plugin-dashboard/src/index.tsx` and `ElementDataSourceGate.tsx`. The
`object-metric` icon input moved `281` → `299` and is still `{ name:
'icon', type: 'string' }`.
- objectui#11619 moved the `page:tabs` and `page:accordion` icon anchors
in `containers.tsx` by +3.
- objectui#11615, objectui#11266 and objectui#8347 moved
`ObjectKanbanSchema.limit`, `ObjectMapConfigSchema` and
`LIST_VIEW_LOCAL_OVERRIDES` in `objectql.ts` and `objectql.zod.ts`.
- objectui#11605 added `view.noObject` to the `en`, `zh` and `de` locale
packs. Their cited `calendar.configRequired` strings did not change or
move.
- One cited line changed content. `ObjectKanban.tsx:10`, the type
import, gained `SortConfig` beside the `ObjectKanbanSchema` the record
cites.
- Two counts were re-taken by their records' own methods, and both read
the same: the `keyboardNavigation` hit lines (15, against the
`schema.editable` control's 3) and the `ElementDataSourceGate`
occurrences in five `src/index.tsx` shells (0, 3, 3, 3 and 4).
- The three quoted anchors in `ui/view.zod.ts`'s map record redded at
this pin, and each was re-read and re-pointed: `case 'map':` moved
`2299` → `2300`, `ObjectMapConfigSchema` `2370` → `2388`, and
`LIST_VIEW_LOCAL_OVERRIDES` `1419` → `1437`.
- **The six migration entries' corpus counts** were re-taken with `git
grep -o -F`. That method first reproduced every `2e818d0b5` number: 7579
files, `objectstack` 17227, `@objectstack/spec` 7134, `timeout` 1351,
`useState` 2477, `TTL` 182, `tenant` 1317 and `Span` 505.
- The new numbers are 7632 files, 17313, 7186, 1360, 2477, 182, 1318 and
508. The other controls read `RuntimeConfig` 276 → 293, `resourceLimits`
2 → 2, `window` 4175 → 4193, `period` 238, `interval` 195 and `metrics`
374 → 401.
- Every zero is still zero: 98 tokens were checked, the export lists of
the three cited spec files plus every named key. The only non-zero
tokens are the expected `Span` (508) and `SpanSchema` (57). The three
new `Span` hits are `colSpan` in objectui's
`object-form-section-field-entry-11615.test.ts`. Both `resourceLimits`
hits are still prose in `packages/app-shell`.
- `packages/spec/src/migrations/registry.ts` was regenerated with
`gen:migration-registry`.
- **`.changeset/objectui-pin-citations-9dfaca654311.md`** is a
`@objectstack/spec` patch, because the `FormField.span` describe and six
migration descriptions name the pin.
`content/docs/references/ui/view.mdx` was regenerated by
`check:generated --fix`.

No example, test or gate needed adapting, and no code changed outside
generated records, citations and changesets.

## Console build and canaries

`build-console.sh` ran locally under the verify lock, after `turbo run
build --filter=@objectstack/client...`, the same two steps the `Console
Pin Gate` job runs. Exit 0, 9m02s on a shared four-core box. The build
log reports:
- "Bundle canary 'import/jobs' present".
- "Single-zod canary: exactly one zod version literal
{major:4,minor:6,patch:5}".
- "Console bundle carries THIS tree's @objectstack/spec, and only it".
- "@objectstack/console dist ready (64192 KB) from
objectui@9dfaca654311".

`check:console-sha` and `check:console-injection` exit 0 against that
dist.

## Browser smoke: `examples/app-showcase` with the Console built at
`9dfaca654311`

The server ran `pnpm dev -- --fresh --ui --no-watch --compile -p 41877`
with `OS_PORT=41877`, on its own ephemeral DB with the seeded admin.
Headless Chromium (`/opt/pw-browsers/chromium`) drove it, signing in
through the console's own login form, with console messages, page errors
and every 4xx/5xx response captured. Only the PIDs this run started were
stopped.

**No showcase object declares `imageField`, so the record picture needs
one to exist.** `git grep imageField examples` gives 0 hits. To exercise
objectui#11619's read, the smoke made a temporary local edit:
- It added `imageField: 'f_image'` to `showcase_field_zoo`, through
`node scripts/ablation-replace.mjs --hold`.
- It compiled and booted the server.
- It restored the file at once with `--restore`. The tool reports the
blob back to HEAD's `4130858b8f8b` and `git diff HEAD` empty, and `git
status --porcelain` is empty.
- After the run, the showcase `dist/` was restored from turbo's cache
for the clean source: sha256 `cc1034dd9d23…`, as before the smoke. The
stray runtime bundle of the edited compile was deleted.

Nothing of the edit is in this diff. A real `sys_file` was uploaded
through `/api/v1/storage/upload/presigned` → `PUT` → `/upload/complete`
(a 64×64 red PNG) and written to "Specimen — Full"'s `f_image`.

| record page | `[data-record-picture]` | image |
|---|---|---|
| Field Zoo "Specimen — Full" (`f_image` set) | 1, beside the title in
the record header, `rounded-md` (an `image` field, not `avatar`) |
`src="/api/v1/storage/files/b1f4e384-…"`, `alt=""`, loaded,
`naturalWidth` 64 |
| Field Zoo "Specimen — Minimal" (`f_image` empty) | 0 | none, as
objectui#11619 specifies for an empty value |

**Verdict:** at this pin the record header draws the record's picture
from the object's `imageField`, drawing the stored file through
`/api/v1/storage/files/:id`, and draws nothing when the value is empty.
`showcase_task`'s record page is the custom `task-detail` page, which
has no `page:header`, so it draws no record chrome to put a picture in.
That page was smoked too: it renders its path bar, highlights and
sections with 0 page errors.

**Console messages across the run:** 0 page errors. The only failed
loads are a 401 on `GET /api/v1/auth/get-session` (the pre-login probe)
and a 404 for `/favicon.ico`. There is no `/api/v1/usage/storage` 404.
objectstack-ai#21625's and objectstack-ai#21710's smokes both recorded one, and objectui#11002
(`d2e859936`) stopped the request on a runtime that does not serve it.

## Gates and tests (head `4b9519ea23`)

- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 127 commands from the 22-path diff, and all were run
at `4b9519ea23`. `--ran` reports "127 derived, 127 run, 0 NOT-MEASURED,
0 UNRUN", with every exit code recorded.
- Two exited 3 (`PREREQUISITE NOT MET`) on the first pass:
`check:skill-examples` (no `client-react` dist) and
`check:dual-build-cjs-loads` (no dist for 34 packages).
- Both exited 0 after `turbo run build --filter=!@objectstack/docs` (71
of 72 tasks were cache hits), and those are the codes recorded.
- Also exit 0:
- `check:objectui-pin-citations --verify-anchors` with objectui at the
pin: 54 asserting citations match `9dfaca654`, and 7 anchor content
assertions are verified.
- `check:objectui-bump` (20 assertions across 5 cases),
`check:sdui-lockstep`, `check-sdui-manifest`, `check:console-sha`,
`check:console-injection`, `check:migration-registry`.
- `@objectstack/spec check:generated`: all 15 artifacts current after
the `--fix`.
- `pnpm --filter @objectstack/spec exec vitest run` (both projects,
`local` and `repo`): 668 files, 19259 passed, 1 todo. `pnpm --filter
@objectstack/spec typecheck`: exit 0.
- `@objectstack/sdui-parser` test (14 files, 225 passed) and typecheck:
exit 0.
- These suites read the regenerated manifest:
- `@objectstack/lint` test: 119 files, 5627 passed. It declares
`sdui.manifest.json` as a test input.
- `@objectstack/metadata-protocol`
`src/protocol.runtime-authoring-gate.test.ts`: 70 passed.
- `@objectstack/cli` unit tier `src/utils/sdui-manifest.test.ts` and
`test/validate-build-gate-parity.test.ts`: 2 files, 79 passed.
- `test/jsx-gate-manifest-notice.e2e.test.ts` belongs to neither CLI
tier; it runs nightly, so it is NOT MEASURED here and is left to CI.
- `eslint --no-inline-config --format json` on the 15 changed TS files:
15 files, 0 errors and 0 warnings. The lint population is
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` (`eslint.config.mjs:971`). The
config enables no type-aware linting (`:327-328`), so this diff cannot
move a verdict on an untouched file. Repo-wide `pnpm lint` is left to
CI.

## Acceptance notes

- `main` moved two commits past this branch's base (objectstack-ai#21783, objectstack-ai#21780).
Neither touches a path this diff touches, so no merge was made. The
merge queue rebuilds on the current `main`.
- Anchors in records that cite no asserting sha are outside the
pin-citation gate's population and were not re-measured. Several point
into files that changed here: `containers.tsx` anchors in
`ui/component.zod.ts` docblocks with no sha, and `component.test.ts`'s
`plugin-dashboard/src/index.tsx:204` (the `ObjectMetricPropsSchema icon
liveness` test, already reading an unrelated line at `ab1879721595`, as
objectstack-ai#21710 noted).
- After this pin, the spec's snake_case grid-key prescription ("these
come in once the widget reads a camelCase spelling") describes a
condition that now holds. objectstack-ai#21768 retargets it when it declares the
camelCase keys. It is left as is here, because the pin bump changes no
accept set.
- Writes: one draft PR through the relay and the report comment. No
label, no PR assignee, no ready flag and no auto-merge were written,
because this dispatch's write budget names none of them.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ers 400 SMS_SERVICE_REQUIRED instead of a bare 500 (objectstack-ai#21858)

Fixes objectstack-ai#21793
Clause-②: yes (widening)

## What changed

`POST /api/v1/auth/phone-number/send-otp` on a deployment with phone
sign-in on but no SMS service that can deliver a code (none wired, or
only the log transport in production) answered **500 with an empty
body**. It now answers **400** with `{ "code": "SMS_SERVICE_REQUIRED",
"message": "..." }`.

- `packages/plugins/plugin-auth/src/auth-manager.ts`: the no-provider
branch of `deliverPhoneOtp` throws better-auth's
`APIError('BAD_REQUEST', { message, code: 'SMS_SERVICE_REQUIRED' })`,
the same construction the sibling refusals in this file use (for example
`PASSWORD_POLICY_VIOLATION`). The message names the missing SMS delivery
service and where an administrator configures it (Setup, Settings, SMS
Delivery), and points the user at phone and password sign-in. It never
carries the one-time code. The quota branch and the delivery path are
unchanged. Four doc comments that said `NOT_SUPPORTED` now name the new
answer.
- `packages/spec/src/api/error-code-ledger.zod.ts`:
`SMS_SERVICE_REQUIRED` is registered for `@objectstack/plugin-auth`,
beside `EMAIL_SERVICE_REQUIRED`. The reference pages
`content/docs/references/api/{contract,error-code-ledger}.mdx` are
regenerated (`gen:docs`, as `check:generated` named).
- `content/docs/permissions/authentication.mdx`: the two passages that
said "fails loudly with `NOT_SUPPORTED`" now state the 400 and the code.
The first also says that `request-password-reset` keeps answering
`{status:true}`.
- `docs/qa/platform-checklist/areas/identity-auth.json`:
`identity-auth.auth-method-matrix` (revision 5) names the shipped
refusal in clause 4, its negative, step 4, the fixtures row and the
variant. A bare 500 is now named a FAIL, and the clause cites the door
pin.
- Changeset: `@objectstack/spec` **minor** (the ledger accepts one more
value), `@objectstack/plugin-auth` **patch** (the bug fix).

## H2: which branch, and the measurement that decided it

**Branch (b): a new registered code.** No registered code honestly names
"phone OTP needs an SMS delivery service" (measured at merge base
`6fb71152c`):

- `@objectstack/plugin-auth`'s row: `EMAIL_SERVICE_REQUIRED` names the
email service. `PHONE_NOT_ENABLED` means the phone plugin is off, which
is not this case. `INVITE_SMS_FAILED` is a failed invitation send. No
other row is about SMS.
- The standard catalog has no SMS member. `SERVICE_UNAVAILABLE` and
`NOT_IMPLEMENTED` misname the cause and are 5xx.
- No other package's row names SMS delivery.

The spelling `SMS_SERVICE_REQUIRED` already exists in this package:
`sendPhoneInviteSms` throws it as a plain-`Error` prefix for the same
condition. So one name now covers one condition. It passes the objectstack-ai#8211
synonym rule, because the token `SMS` is in no standard member. The
status is **400**, the status of the email sibling
(`admin-import-users.ts` answers `EMAIL_SERVICE_REQUIRED` with
`fail(400, ...)`).

## Mechanism hypotheses, measured

All door readings use the real `AuthManager.handleRequest` over the
installed better-auth 1.7.3 / better-call 1.4.0.

- **H1, confirmed.** Before the fix, the no-provider branch answered
`500`, no `content-type`, body `""` (measured under the ablation below).
The quota branch answered `429`, `application/json`, `{"message":"Too
many verification codes requested. Please try again later."}`, with no
`code` field. After the fix, no-provider answers `400`,
`application/json`, `{"message":"Phone verification codes are
unavailable: ...","code":"SMS_SERVICE_REQUIRED"}`. The quota answer is
unchanged.
- **H2:** see above.
- **H3:** 400, from the email sibling.
- **H4, confirmed** at objectui `9dfaca65` (the `.objectui-sha` pin).
`packages/auth/src/createAuthClient.ts` `postPhoneNumberEndpoint` reads
the top-level `payload.code` and `payload.message` of this vendor-shaped
body. `LoginForm.handleSendOtp` shows `errorMessages[code]` if one is
mapped, and the message otherwise. Before the fix the payload was
`null`, so the user saw "Auth request failed with status 500".

## Tests

- New
`packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts`
is the door pin. It sends a real request through a real better-auth
pipeline and a pinned memory engine. It checks:
- With no SMS service: `400`, `code === 'SMS_SERVICE_REQUIRED'`, and
`ErrorCode.safeParse(code)` succeeds, so the code is registered.
  - The refusal text never contains the OTP that better-auth stored.
- With `NODE_ENV=production` and a log-only transport: the same 400 and
code, and nothing is sent.
- `request-password-reset` for a registered number still answers `200
{status:true}`. A pass-through spy proves the route reached the refusing
send.
  - Outside production, a log-only transport still delivers.
- In `auth-manager.test.ts`, the old `rejects.toThrow(/NOT_SUPPORTED/)`
case became two cases on the error object: `isAPIError`,
`BAD_REQUEST`/400, `body.code`, and no code in the message.
- **Ablation.** The plain `Error` was put back through
`scripts/ablation-replace.mjs` (mutation landed: anchor 1 to 0, blob
`9d24bb3f` to `9c6b1b90`). Result: **5 failed / 282 passed**. That is 3
door cases (`expected 500 to be 400`, and the deliver spy rejects with
`Error: NOT_SUPPORTED...` instead of the 400 shape) and 2 unit cases
(`isAPIError` false, `statusCode` undefined). The tool's restore leg
proved blob == HEAD `9d24bb3f` and an empty `git diff HEAD`. The first
attempt was refused by the tool before any test ran, because the
replacement text contained the anchor. It was redone with a whole-block
anchor.
- `pnpm --filter @objectstack/plugin-auth exec vitest run`: 120 files,
2515 passed, 10 skipped (at `b37edd67`). Typecheck exit 0. After the
last test-file edit, the two changed files were re-run at `3e8ab846`:
286 passed.
- `pnpm --filter @objectstack/spec exec vitest run`: 668 files, 19286
passed, 1 todo (at `b37edd67`). Typecheck exit 0 (at `3e8ab846`).
- `pnpm --filter @objectstack/spec check:generated`: all 15 artifacts up
to date, after a spec rebuild on the final merge `c6b17163`.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `c6b17163` derived 124 commands. All 124
were run on that head and exited 0. `--ran` with the exit codes
recorded: 124 derived, 124 run, 0 NOT-MEASURED, 0 UNRUN. On the first
pass (at `b37edd67`), `check:engine-double-contract` and
`check:objectql-double-limit` were red on the new test's engine double.
The double now applies `limit`/`offset` by presence, and `--write`
recorded the pinned double (additions only). The local scope is the
targeted set above; the rest of the farm is CI's.

## Riding comments (domain:spec pointer 5989650462)

These are comment-only edits in `error-code-ledger.zod.ts`. No code,
status or owner moved. Each claim was checked against the tree:

1. `DRIVER_UNAVAILABLE` (`@objectstack/cloud-connection`):
**rewritten.** Measured: the only emitter is the purge-sample-data door
(`marketplace-install-local-plugin.ts`, the `!ql || !metadata` branch,
500, introduced by objectstack-ai#21773). So the comment now states that condition,
rather than adding it as an "also".
2. `RESEED_SKIPPED`: **not edited. The claim does not hold on this
tree.** Since objectstack-ai#21780 (`e09f1aca`), a walled session with no active
organization gets `403 PERMISSION_DENIED` on the purge (and on the
reseed), through `NO_ACTIVE_ORGANIZATION_CODE`. `RESEED_SKIPPED` is now
emitted only by the reseed, for its other declines. The existing comment
("reseed declined to run; message carries why") is accurate.
3. `OS_PROTOCOL_INCOMPATIBLE` (`@objectstack/metadata-core`):
**rewritten** to name both doors. `POST /api/v1/packages`
(`runtime/src/domains/packages.ts`) and, since objectstack-ai#21805, `POST
/api/v1/marketplace/install-local` both answer through the shared
`protocolIncompatibleAnswer`.

## Acceptance notes

- The quota branch answers 429 with **no** `code` in its body (measured
above). This is deliberate: `auth-manager.test.ts` pins `bodyCode:
undefined`, so both walls look the same from outside. It is untouched
here.
- `sendPhoneInviteSms` still throws a plain
`Error('SMS_SERVICE_REQUIRED: ...')` when no SMS service is wired. No
door reaches that throw, because its one caller gates on
`isSmsServiceAvailable()` first. It is the delivery path, so it was out
of scope and is unchanged.
- Files outside the expected surface, all made false or required by this
change: the checklist item, the two regenerated reference pages, and
`scripts/engine-double-contract.pinned.json`. That last one records the
new pinned test double, written by `check-engine-double-contract.mjs
--write`, additions only.

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

---------

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

3 participants