Repository navigation
fix(cloud-connection): install-local purge deletes seed rows through the engine by their seed key - #21773
Conversation
…the engine by the seed key The purge looked up a bare 'driver' service no kernel registers (always 500 DRIVER_UNAVAILABLE) and, behind it, matched seed records by an id they do not carry. It now resolves the objectql engine, keys rows by each dataset's externalId in the install's own scope, and deletes child before parent under the seed write context. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
…e the bare-driver mocks Unit pins for purgeSeedRows (seed-key match, user rows untouched, no guessing on ambiguous or keyless records, engine refusals counted as errors, child-before-parent under the seed write context, organization scope) and for the route (real SeedLoaderService order, wall scope, no-active-org refusal). The suites that mocked a bare 'driver' service no kernel registers are rewritten against the engine, or drop the mock. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
Installs the CRM example from its build-shaped artifact through the
install-local door on the showcase, adds a user-authored row, purges and
reseeds: { deleted: 28, skipped: 0, errors: 0 }, the user row kept, every
delete seen by beforeDelete/afterDelete and the audit trail. A second boot
under an organization wall purges one organization and leaves the other's
28 seed rows untouched. Dogfood declares cloud-connection and aliases it to
source, the way it does for its other subjects.
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
Co-authored-by: Claude <noreply@anthropic.com>
…es combinators in its matcher; ratchet slot-lookup down marketplace-install-local-plugin.ts reads one fewer service slot without a type (the bare 'driver' lookup is gone): 16 -> 15. Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 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 1f251d7607ecf42d5844d1f44c9ac6cd598ec50f && git checkout 1f251d7607ecf42d5844d1f44c9ac6cd598ec50f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 025008ae954311ab91ef264d1b750a00ed193b32 2b140e985e2d428adf89db3b464eeb652f08af01 && git checkout -B drift-repro 025008ae954311ab91ef264d1b750a00ed193b32 && git merge --no-ff 2b140e985e2d428adf89db3b464eeb652f08af01
node scripts/docs-audit/affected-docs.mjs --json 025008ae954311ab91ef264d1b750a00ed193b32
|
… with no active organization (ADR-0123 D2/D4) (objectstack-ai#21780) Fixes objectstack-ai#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 objectstack-ai#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](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…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>
Fixes #21728
Clause-②: no
POST /api/v1/marketplace/install-local/:manifestId/purge-sample-dataanswered500 DRIVER_UNAVAILABLEon every runtime. It now deletes an installed package's seed rows through the ObjectQL engine, matched by the seed's own key, in the install's own scope.What was wrong (three defects, stacked)
driverservice. No kernel registers that name; drivers register asdriver.NAME. So the door always refused.rec.id. The CRM example's 28 records carry noid(they key byname/email/subject), so fixing the lookup alone answersdeleted: 0, skipped: 28. Ablation leg 2 below measures exactly that.deletedirectly, past every engine hook and the audit trail.The unit suites stayed green because four of them mocked a bare
driverservice.What changes
packages/cloud-connection/src/marketplace-install-local-purge.ts(purgeSeedRows).externalIdis read through the spec's own declaration (SeedSchema.shape.externalId, defaultname). Rows are keyed the waySeedLoaderServicekeys them for an upsert. A row whose key no seed record declares is never touched.['team', 'project']junction) are translated through the parent rows this purge matched, as the loader does for same-load targets.errors, with the reason logged. None of them counts asskipped, and none deletes anything.SeedLoaderService.buildDependencyGraph), so children go before parents. No order is written by hand.engine.delete(object, { where: { id } })underSEED_WRITE_EXECUTION_CONTEXT, the posture the seed was written with. Record-change automation is suppressed; lifecycle hooks and audit run.handlePurgeresolvesobjectqlandmetadata(500 DRIVER_UNAVAILABLEif either is missing, now naming which). It picks the scope with the install's own two primitives,organizationWallActiveandresolveActiveOrgId, and hands both topurgeSeedRows. The docblock's "bypass ACL / lifecycle hooks (same pattern as cloud purge)" sentence is gone. The response shape is unchanged.drivermocks.deletethat opens withassertEngineDeleteDispatch: heal and capability-enumeration, the two suites that drive the purge.patchon@objectstack/cloud-connection. This is a bug fix in a released package. It adds no export and changes no schema; the door now does what its documented response says.Zone 2 measurements
A1, reach (
origin/main025008a, unchanged code).os dev --freshwithOS_CLOUD_URL=off, thenos package install examples/app-crm/dist/objectstack.jsonas the admin. Result: 3/3/12/5/5 rows, all carrying the default organization.POST …/purge-sample-data {}answered500 {"code":"DRIVER_UNAVAILABLE",…}. Afterwardscrm_accountstill held 3 rows andcrm_opportunity12.422 RESEED_NO_ROWS.200 {"deleted":28,"skipped":0,"errors":0}and the user rowUser Authored Cosurvived.sys_audit_loggained 28deleterows.200 inserted: 28. A second purge deleted 28, and a third answeredskipped: 28.A2, scope. The install and the reseed seed under
organizationWallActive(ctx).resolveActiveOrgId's organization, and the loader pinsorganization_idon both its upsert match and its writes.single, the card's boot): no organization is pinned, and the loader's upsert match is table-wide. Measured at the door:POST /auth/organization/createon that boot answers403 "Creating additional organizations is disabled on this deployment.", so the table is one tenant.400 RESEED_SKIPPED,Purge did not run: multi-tenant-no-active-org….A3, key. The CRM's five datasets declare
externalIdname/email/name/email/subject(3+3+12+5+5 = 28), with 0 authored ids. The loader writes the key to that same field (loadDataset:dataset.externalId || 'name';externalIdKey). The purge matches on exactly that field, in scope.A4, through the engine.
SEED_WRITE_EXECUTION_CONTEXT(isSystem,skipTriggers,seedReplay).triggerHooksruns every lifecycle hook unlessskipAutomationsis set.beforeDeleteandafterDeleteeach fired once per seed row (28), and the audit plugin wrote 28deleterows.SeedLoaderServicewith a manifest listing the child first.Acme Corp(crm_opportunity.accountis required +set_null), the engine refused that account's delete ("still referenced by 1 Opportunity record(s)… required and cannot be cleared"). The purge answereddeleted: 27, errors: 1with the reason logged, and the user's row was untouched.beforeInsert/beforeUpdate.A5, ledger flag.
sampleDataPurged/withSampleDatastay install-wide; the ledger shape is unchanged here.GET /install-localread as B answerswithSampleData: falsewhile B still holds its 28 rows.A6, reverse verification. Both legs ran through
scripts/ablation-replace.mjsonmarketplace-install-local-purge.ts, which the dogfood resolves through its source alias, so no build leg was needed.const result = await engine.delete(…SEED_WRITE_EXECUTION_CONTEXT });→const result = true as boolean | number;rec.idconst ids = rowsByKey.get(keyOf(parts)) ?? [];→const ids: string[] = rec?.id ? [String(rec.id)] : [];deleted: 0, skipped: 28git diff HEADis empty.Tests (at 2b140e9)
pnpm --filter @objectstack/cloud-connection exec vitest run: 37 files, 459 tests pass. The newmarketplace-install-local-purge.test.tshas 16 cases (match, no-guessing, order/context/scope, reference parts, and the route under the real loader).pnpm --filter @objectstack/cloud-connection typecheck: clean (both programs).install-local-purge-sample-data.dogfood.test.tson a real boot: 8/8 pass.pnpm --filter @objectstack/dogfood typecheckis clean.pnpm lint(full,eslint . --no-inline-config): exit 0.Gates (at 2b140e9)
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 86 families (the claim's 46 grew with the dogfood wiring, the lockfile and the ratchet baseline).--ranreconciles 86 derived / 86 run / 0 NOT-MEASURED, every line carrying its exit code.check:dual-build-cjs-loadsfirst answered PREREQUISITE NOT MET (eight unrelated packages unbuilt). After building them it read 106 entry points across 66 packages, green.check:slot-lookupratcheted down 16 → 15 for the plugin file (the untypeddriverlookup is gone). The baseline is committed.Deviations
packages/qa/dogfood/package.json(devDependency@objectstack/cloud-connection) andpackages/qa/dogfood/vitest.config.ts(one anchored source alias), plus their lockfile importer lines.@objectstack/runtimeimport into dogfood's shrink-onlycheck:test-source-aliasset (NEW unaliased artifact import(s): @objectstack/runtime).400 RESEED_SKIPPEDwhen a wall is up and the session has no active organization. This was dispatched as "reseed's existing skip shape and code". A dedicated code would need a spec ledger row (domain:spec).@objectstack/cloud-connectiongains a devDependency on@objectstack/metadata-core, forassertEngineDeleteDispatchin its fake engines, ascheck:engine-double-contractprescribes. It is aliased to source in its vitest config.origin/mainstayed at 025008a from dispatch to this PR, sogit merge origin/mainis a no-op.Acceptance notes
idand replayed into a second organization gets derived ids (perOrganizationSeedRowId, private to the loader), so the purge answersskippedfor those rows. No install-local package measured has one.cascade.withSampleData: falseis written even whenerrorsis non-zero, which is the pre-existing flip.error-code-ledger.zod.ts,DRIVER_UNAVAILABLE's note says "no driver service", andRESEED_SKIPPED's says "reseed declined", but the purge now emits both.docs/qa/platform-checklist/areas/platform-core.jsonstill describes the purge as id-based.Generated by Claude Code