Repository navigation
fix(auth): TOTP enrollment names the deployment app name as its issuer, not Better Auth - #21752
Conversation
better-auth fell back to its own name for the otpauth issuer and label because its config had no appName. Pass AuthManager.getAppName() as the better-auth appName, which reaches both /two-factor/enable and /two-factor/get-totp-uri. objectstack serve now hands AuthPlugin the deployment app name from the resolver the email capability already uses, so OS_APP_NAME reaches auth at all. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 35 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 2e95b03c8ce44ebb0efb2d64e51dfee7d0455317 && git checkout 2e95b03c8ce44ebb0efb2d64e51dfee7d0455317
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ea7ff394b6d802de9ad0448130bef86d4407760d e7e32f4f140686fa59eb74bd772d35ff73a5c5dc && git checkout -B drift-repro ea7ff394b6d802de9ad0448130bef86d4407760d && git merge --no-ff e7e32f4f140686fa59eb74bd772d35ff73a5c5dc
node scripts/docs-audit/affected-docs.mjs --json ea7ff394b6d802de9ad0448130bef86d4407760d
|
… the telemetry sibling on the standalone stack (objectstack-ai#21733) (objectstack-ai#21766) Fixes objectstack-ai#21733 Clause-②: yes (widening) ## What this changes A plain `os dev` on a non-host config (every fresh scaffold) boots through the standalone stack, and that path now gets the two dev behaviours `content/docs/deployment/cli.mdx` already documents: 1. **Dev schema self-heal.** On restart, safe drift (`autoMigrate: 'safe'`) is applied. The card's staged non-NULL-safe unique index comes back NULL-safe, and the drift and plan lines saying "auto-applied at boot under dev autoMigrate: 'safe'" are now true. Their wording is unchanged. 2. **Telemetry sibling.** The `DB.telemetry.EXT` sibling datasource is provisioned next to a file-backed SQLite primary under the same `resolveTelemetryDbPath` rule. `OS_TELEMETRY_DB=0` opts out. Production boots and one-shot command boots do not change. ## Route (A2): one decision, in `@objectstack/runtime` - **New home.** `packages/runtime/src/dev-auto-migrate.ts` exports `devAutoMigrateConfig(driver, dev)`. It decides two things in one place: - when: an explicit dev boot; - for which kinds: those whose spec connection contract declares `autoMigrate`, i.e. sqlite, postgres and mysql. - **Three readers, none with its own condition:** - the standalone stack's `default` definition (`standalone-stack.ts`); - every arm of `resolveStorageDefinition` (`storage-driver.ts`, the host-config path); - the telemetry sibling's driver. - **Why runtime.** The dependency direction allows cli → runtime only, and `service-datasource` and `spec` are out of lane. The move adds two exports to `@objectstack/runtime`'s only entry, `devAutoMigrateConfig` and `DevAutoMigrateConfig`. That is this PR's published-surface widening, hence `Clause-②: yes (widening)` and a `minor` on runtime (see the changeset). - **Per-kind behaviour is unchanged.** The host path already gave `'safe'` to exactly sqlite, postgres, mysql and the dev-default `:memory:` sqlite, and none to sqlite-wasm, mongodb or turso. Two pins hold this: - `dev-auto-migrate.test.ts` holds the helper's set equal to the spec contract; - `dev-self-heal-host-parity.test.ts` drives both real entry points for all 6 kinds × dev/production. All 12 rows agree. ## One-shot fence (A3) - **The risk.** `bootSchemaStack` (`schema-migrate.ts`, every `os migrate *` / `os meta *` / `os secret *` boot) passes no `dev`. So `factoryDev` falls back to `NODE_ENV === 'development'`. - **The fix.** The standalone stack therefore reads the decision under `cfg.dev === true` only. The step-down keeps its `NODE_ENV` default. `os dev`, `os serve` and `os start` always pass `dev` explicitly, from `serve.ts` and through `createDefaultHostConfig`. - **Measured** by ablation leg C below: with the read keyed on `factoryDev` under `NODE_ENV=development`: - a **non-deferred** one-shot boot applied the staged safe drift (fence pin red, `expected [] to include 'safe:recreate_index'`); - `os migrate plan` stayed write-free, because its deferred DDL never reaches the reconcile (pin green). - **Pins** are in their own file, `schema-migrate.dev-self-heal-fence.integration.test.ts`: - plan lists the drift as `safe` and leaves the staged file byte-identical; - a non-deferred `bootSchemaStack` leaves the drift in place; - the serving declaration (`dev: true`) heals the same file, as a positive control. ## Telemetry on the standalone path (A4) - **Shared provision.** The inline provision in `serve.ts` became `provisionTelemetryDatasource` (`utils/telemetry-datasource.ts`). Both serving paths call it through one closure: - the host branch keys it on `resolution.sqliteFilePath`; - the standalone branches key it on `standaloneTelemetryPrimary(input)`, which is the runtime's own pre-boot `resolveStandaloneDatabase` over the same input the stack was handed. Only the native `sqlite` kind counts, as on the host path. - **Banner.** It needed nothing new. `servedSqliteFilePath` on this path is still read from the booted driver, and the plugin roster gains `TelemetryDatasource` exactly as on the host path. - **Falsifier.** No deliberate omission was found: no comment, test or ruling in `standalone-stack*`, `default-host*`, the CLI tests or ADR-0057/0062. ADR-0062 records the telemetry sibling as a pre-built `DriverPlugin`, and it stays one. ## Reproduction (A1), origin/main `8cbba54491`, built CLI (`bin/run.js`) The scaffold is one object `scaf_item` with `qa_code: { unique: true }`, `sharingModel: 'private'`, and no plugins. - **Boot 1** (`os dev --no-watch -d file:A.db`) created the NULL-safe index ``CREATE UNIQUE INDEX `uniq_scaf_item_organization_id_qa_code` ON `scaf_item` (COALESCE(`organization_id`, '__global__'), `qa_code`)``. No `A.telemetry.db` was created. - **Stage.** The index was restaged as `CREATE UNIQUE INDEX uniq_scaf_item_organization_id_qa_code ON scaf_item (organization_id, qa_code)`. `os migrate plan --database-url file:A.db` then listed `✓ scaf_item [uniq_…] [recreate_index]` under Safe, and the file was byte-identical afterwards. - **Two plain `os dev` restarts** each logged `[schema-drift] … (auto-applied at boot under dev autoMigrate: 'safe')`, and the index stayed bare both times. - **Control.** The same staged file under `OS_MODE=off` logged `[schema-drift] auto-reconciled recreate_index on scaf_item.organization_id`, the index came back NULL-safe, and `A.telemetry.db` was created. - **After the fix,** the same staged file with a plain `os dev` logs `auto-reconciled recreate_index`, the index comes back NULL-safe, and `A.telemetry.db` is created. It holds `sys_audit_log`, `sys_job_run`, `sys_notification` and 4 more tables. ## Tests Full suites and lint ran at `c20d26dd76` (round 1). Round 2 merged `origin/main` `b7a13c762f` and reran the targeted set at `3e28108544`, listed under the table. | Run | Result | |---|---| | `pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2` (full runtime suite) | 324 files, 4617 passed, 19 skipped | | `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` (full) | 258 files, 3777 passed | | `… --project integration --maxWorkers=2 --shard=1/4 … 4/4` (full, 93 files) | 24/23/23/23 files; 169+267+238+223 passed, 2 skipped | | `pnpm --filter @objectstack/runtime typecheck` / `@objectstack/cli typecheck` (incl. test layers) | green | | `pnpm lint` (full, `eslint . --no-inline-config`) | exit 0, 99 s; rerun at `3e28108544`: exit 0, 104 s | Round 2 at `3e28108544`, after the merge and a full turbo rebuild of `./packages/*` and `./packages/*/*` (71 tasks): - `pnpm --filter @objectstack/runtime typecheck` and `@objectstack/cli typecheck`, including the test layers: green. - runtime `src/default-datasource-plugin.test.ts` and `src/dev-auto-migrate.test.ts`: 2 files, 19 passed. - the four CLI pins below, on the rebuilt packages: 4 files, 13 passed. The new pins: - `packages/runtime/src/dev-auto-migrate.test.ts`: contract equality, plus the `default` definition under dev, production, and the `NODE_ENV=development` default. - `packages/cli/src/utils/dev-self-heal-host-parity.test.ts`: both hosts, 6 kinds × dev/production. - `packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts`: the one-shot fence, in its own file. - `packages/cli/src/utils/telemetry-datasource.provision.integration.test.ts`: the provision, the `OS_TELEMETRY_DB=0` opt-out, production opt-in only by explicit path, and `standaloneTelemetryPrimary`. - `packages/cli/test/dev-standalone-self-heal.integration.test.ts`: the card's restart pin on the **built** CLI. - fresh `os dev` with `OS_TELEMETRY_DB=0`: NULL-safe index, no sibling; - restart on the restaged bare index: NULL-safe again, `auto-reconciled`, `A.telemetry.db` present; - production `os serve` (`NODE_ENV` unset) on the restaged file: the drift is reported and left bare. ## Reverse verification (A5): `scripts/ablation-replace.mjs`, rebuilt, `ablation-dist-preflight` both legs All three legs follow the same sequence: 1. The anchor hits once, and the blob changes. 2. `dist/` carries the marker (preflight exit 0). 3. Measure. 4. Restore: the blob equals HEAD and `git diff HEAD` is empty. 5. Rebuild, then preflight `--absent`: the marker is gone from `dist/` and the tree is clean. | Leg | Mutation (`standalone-stack.ts` / `serve.ts`) | Blob | Red | Green | |---|---|---|---|---| | A: the standalone read of the decision | `config: { ...driverConfig, ...devSelfHeal }` → `...(String('ABLATED_21733_A') ? {} : devSelfHeal)` | `69295ae4dcb6` → `3a20c5f19924` → `69295ae4dcb6` | restart NULL-safe pin; fence positive control; host parity; runtime dev-definition ×2 | production pin; fresh pin; telemetry pin; both one-shot fence cases; `storage-driver.test.ts` (host path); runtime contract, production and NODE_ENV cases | | B: the standalone telemetry provision | `standaloneSqlitePrimary = await standaloneTelemetryPrimary(standaloneInput);` → `String('ABLATED_21733_B') ? undefined : …` | `d5486ac90717` → `42870a5cbc82` → `d5486ac90717` | telemetry-sibling pin | self-heal, opt-out, production pins | | C: the one-shot fence (A3) | `devAutoMigrateConfig(dbDriver, cfg.dev === true)` → `… String('ABLATED_21733_C') ? factoryDev : cfg.dev === true` | `69295ae4dcb6` → `22ebf7f09362` → `69295ae4dcb6` | non-deferred one-shot fence; runtime NODE_ENV-default case | `os migrate plan` byte-identical; serving positive control | ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `3e28108544` (merge base `b7a13c762`, 14 paths) derives the same 86 families as round 1. All 85 runnable families were rerun on that head and passed. That includes `check:changeset-no-major --base origin/main` (no `major`) and `check:adr-0087-registration` (no declared-breaking changeset). Round-1 notes, at `c20d26dd76`: - **Rerun after a prerequisite.** `check:dual-build-cjs-loads` and `check:i18n-coverage` first answered `PREREQUISITE NOT MET` (exit 3) on unbuilt packages. They were rerun green after building those packages. - **Rerun after a fix.** `check:cli-test-child-env` went red on the new built-entry spawner and was rerun green. The fix is in the second deviation below. - **NOT MEASURED: `check:pm-dispatch-gates`.** Its self-test and its bare check each ran past the 10-minute foreground cap at 590 s on this shared box, with every printed case passing. It gates `scripts/pm/dispatch-gates.mjs`, which this diff does not touch. CI runs it. - **Carried.** `check:pm-dispatch-gates` is carried NOT MEASURED into round 2. Its inputs (`scripts/pm`, `.github`, `.claude`, the root `package.json`) are byte-unchanged between `c20d26dd76` and `3e28108544`. - **Reconciled.** `--ran` at `3e28108544` reports `86 derived famil(ies) accounted for — 85 run, 1 NOT-MEASURED`. - **Ledger blind spot.** `git grep autoMigrate` over `packages/spec/liveness/**` and `*.ledger.*` found 0 hits; the control term `sqlite` hits `datasource.json`. No symbol was renamed or removed. ## Deviations for the seat - **`@objectstack/runtime` gains two exports.** `devAutoMigrateConfig` and its `DevAutoMigrateConfig` type, on the package's only entry. This is the widening declared above. - No key is added to `StandaloneStackConfigSchema` or to any other exported type or schema, and no accept set moves. - The `dev` key's TSDoc now states its narrower self-heal meaning: an explicit `true` only. - The changeset is `minor` on `@objectstack/runtime` and `patch` on `@objectstack/cli`. - **`scripts/check-cli-test-child-env.mjs` census.** Its pinned built-entrypoint population (formerly "exactly the six files") now admits `dev-standalone-self-heal.integration.test.ts`, with a comment saying why. The production leg is only reachable through `bin/run.js` with `NODE_ENV` unset. The gate's 152 self-test cases pass. - **origin/main merged.** `b7a13c762f` came in through a merge commit (`07745648c0`, no rebase), with no conflicts. One file overlaps, `serve.ts`, where objectstack-ai#21752 adds the AuthPlugin `appName`; its hunks are disjoint from this diff. After the merge, the branch's diff against `main` was the same 13 files with the same +1134/−68 as before; round 2's R4 TSDoc edit makes it 14 files, +1143/−69. The rebuilt `serve.js` carries both sides. ## Acceptance notes - **`DefaultDatasourcePluginOptions.dev` TSDoc** (`packages/runtime/src/default-datasource-plugin.ts`), comment only, fixed in round 2. It used to say `dev` arms the "self-heal passthroughs". It now says what `dev` arms, the sqlite step-down (objectstack-ai#2229), and that the self-heal rides in the definition's `config.autoMigrate`, which `devAutoMigrateConfig` decides. - **Artifact-fallback branch.** The `createDefaultHostConfig` branch (`os start` with no config, `os dev -a`) gets both behaviours too. Its telemetry primary is resolved from the same input handed to `createDefaultHostConfig`. The empty-boot stub declares no datasource, so the database answer cannot differ. This is commented at the call site. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…cision in words instead of a tracker number (stage 13) (objectstack-ai#21763) Part of objectstack-ai#20749 Clause-②: no Stage 13 of this card, and the fourth area of class (e): the test strings shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513. This stage takes two whole directories, `contracts/` and `conversions/`. Their 97 test-title and test-string literals carried 108 tracker ids citing 78 records. 107 ids in 96 literals now either state what their record decided, in words (form D), or are dropped where the title already says it. One id stays, for the reason given below. Text only: no assertion, identifier, test count or code comment changes. ## Census at the base (`866b4393d0`, the claim's base) Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`), `census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`) and `census-wide.cjs` (md5 `c98410a19529c439adb0afbfb00026a2`), byte-identical to the copies stages 10 to 12 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. Both instruments read **1509 messages / 1606 ids in 348 files at the base**, which is stage 12's head reading exactly. `contracts/` reads 63 / 74 and `conversions/` 34 / 34, also stage 12's figures. | directory | files | messages / ids | titles | other | |:--|--:|--:|--:|--:| | `data/` | 95 | 468 / 501 | 445 / 475 | 23 / 26 | | `ui/` | 81 | 392 / 415 | 374 / 397 | 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 | | **`contracts/`** (this PR) | 25 | **63 / 74** | 59 / 70 | 4 / 4 | | **`conversions/`** (this PR) | 9 | **34 / 34** | 34 / 34 | 0 | | `security/` | 8 | 28 / 28 | 28 / 28 | 0 | | `ai/` | 9 | 18 / 20 | 13 / 15 | 5 / 5 | | `identity/` | 6 | 15 / 15 | 14 / 14 | 1 / 1 | | `integration/` | 4 | 14 / 14 | 13 / 13 | 1 / 1 | | `migrations/` | 2 | 9 / 12 | 9 / 12 | 0 | | `marketplace/`, `meta-spelling/`, `studio/` | 5 | 7 / 7 | 7 / 7 | 0 | | **total** | **348** | **1509 / 1606** | **1422 / 1515** | **87 / 91** | - **Controls.** Lit, a title: `contracts/data-engine.test.ts:575` reads one message with four ids. Lit, an `expect` message: `contracts/metadata-service-roundtrip-conformance.test.ts:142` reads one message. Dark: the comment at `contracts/core-service-contracts.test.ts:3` ("[objectstack-ai#4127] The map claims a binding per slot") reads 0. Planted in a scratch copy: an id put back into a title reads 1 / 1, and an id in an added comment reads 0. - **A wider pattern** (any `#` plus digits) reads 63 / 75 under `contracts/` and 35 / 36 under `conversions/` test files at the base. The extras are `(batch objectstack-ai#76)` in the `resume-failure-report.pin.test.ts:137` title, and `PD objectstack-ai#12` (Prime Directive 12, contract-first) in two `conversions.test.ts` titles, `:180` and `:785`. None matches the gate's 3-to-5-digit pattern. - **At the head:** 1413 messages / 1499 ids in 315 files. `contracts/` reads 1 / 1 (the needle below), `conversions/` 0 / 0. Nothing else moved. The wider pattern reads that needle and the two `PD objectstack-ai#12` titles, and nothing else. ## How the area was chosen Stage 10's rule: rank whole first-level directories by ids, and take the busiest within about 10% of the ~100-id bound. `data/` (501), `ui/` (415), `api/` (201) and `system/` (165) each exceed it alone, and the files directly in `src/` (120) are 20% over. No single remaining directory fits except smaller ones, and `contracts/` with `conversions/` reads exactly 108, within 10% of the bound. That is the pairing the stage-10 ACCEPT named, so the rule needed no second pass. **Named for the next stages:** `data/` (about five stages, by subdirectory or file group; `data/driver/` alone is 52), `ui/` (about four), `api/` (two), `system/` (two), the files directly in `src/` (one, 120), and `security/`, `ai/`, `identity/`, `integration/`, `migrations/`, `marketplace/`, `meta-spelling/` and `studio/` together (one, 96). ## What each id became 32 ids in 21 literals now state a decision in words. 75 ids in 75 literals are dropped where the title already says what the record decided. Every cited record was read with its comments through REST: 71 answer 200, and 7 answer 404 (objectstack-ai#6345, objectstack-ai#6523, objectstack-ai#11741, objectstack-ai#12010, objectstack-ai#12248, objectstack-ai#16559, objectstack-ai#16786). For those seven the decision was read from what landed: the landing commit and the CHANGELOG entry. | record(s) | literal | now reads | |:--|:--|:--| | objectstack-ai#16293 | `action-confirmation-contract.pin.test.ts:63` | "action-confirmation contract — an unconfirmed gated action is refused". The ruling: a gated action without an explicit confirmation is refused loudly, with the way to confirm. | | objectstack-ai#10331 | `approval-service.test.ts:23` | "approval rows declare the organization_id they are stamped with". The finding's first reading landed: both row types declare it, optional and nullable. | | objectstack-ai#19846 | `automation-context-caller-param-keys.pin.test.ts:49` | "AutomationContext.callerParamKeys — the keys the caller supplied". The ruling replaced the headless-screen inference with this explicit signal. | | objectstack-ai#18235 | `automation-service.test.ts:422` | "FlowRuntimeState — carries the reason a flow is unbound", so a policy-disabled flow reads differently from a broken binding. | | objectstack-ai#15937 | `confirmed-blueprint-identity-contract.pin.test.ts:52` | "confirmedBlueprintIdentity — declared on the protocol ToolExecutionContext". The maintainer chose option 1: declare it in the protocol, not only on cloud's augmentation. | | objectstack-ai#11493 (2) | `data-driver.test.ts:290`, `data-engine.test.ts:455` | "introspectSchema — an optional driver member at the spec shape" and "introspectDatasource — answers the spec introspection shape". Both ruled steps. | | objectstack-ai#12248, objectstack-ai#11833 | `data-engine.test.ts:514` | "datasource resolution members — declared, and optional". Fork 1 of the objectstack-ai#11833 ruling, option A, landed as `8425c17cc`. | | objectstack-ai#12248, objectstack-ai#12010, objectstack-ai#12805, objectstack-ai#11833 | `data-engine.test.ts:575` | "datasource lifecycle members — declared at the shape the engine keeps". Item 4 of the objectstack-ai#11833 ruling put `ConnectionEngineLike`'s members on the contract (`8425c17cc`, `77b91bd`), and objectstack-ai#12805 caught the declared def up to what the engine retains. | | objectstack-ai#12482, objectstack-ai#12010, objectstack-ai#11833 | `data-engine.test.ts:679` | "syncObjectSchema — declared, since two services already call it". | | objectstack-ai#5040 | `http-server.test.ts:183` | "optional setFallbackHandler — a not-found hook, not a wildcard route". The design's option C, so a declared endpoint never shadows a registered route. | | objectstack-ai#9835 | `http-server.test.ts:316` | "optional afterResponse — a transport-agnostic response observer". | | objectstack-ai#6617 | `job-service.test.ts:126` | "JobHandler degraded-outcome channel — optional and additive". | | objectstack-ai#14766, objectstack-ai#14501 | `job-service.test.ts:263` | "IJobService.replay force option — a succeeded window replays only when forced". The A + a2 ruling. | | objectstack-ai#4127 | `notification-service.test.ts:153` | "inbox — declared on the contract, and optional". | | objectstack-ai#5928 | `objectql-engine-hook-scope.test.ts:41` | "IObjectQLEngine.registerHook scope faces — global minus excluded objects". The ruled A shape, `excludeObjects`. | | objectstack-ai#12248, objectstack-ai#11833 | `objectql-engine.test.ts:29` | "getObject return contract — a structured answer, not `unknown`". Fork 3. | | objectstack-ai#12481, objectstack-ai#12248, objectstack-ai#11833 | `objectql-engine.test.ts:106` | "getSchema return contract — the same answer as its alias getObject". Fork 3, one member over. | | objectstack-ai#20157, objectstack-ai#19995 | `objectql-engine.test.ts:163` | "judgeFilter contract — the engine judges a filter without running it". Ruling C. | | objectstack-ai#4539 | `sharing-service.test.ts:35` | "recipient vocabularies, each under its own name". The same-name, different-form exports were split by renaming. | | objectstack-ai#5858 | `sharing-service.test.ts:194` | "HierarchyScopeContext tenancy authority — organizationId is authoritative". | **Dropped only (75 ids):** objectstack-ai#3903, objectstack-ai#4045, objectstack-ai#4127 (2), objectstack-ai#4158, objectstack-ai#4251, objectstack-ai#4343, objectstack-ai#4347 (2), objectstack-ai#4401, objectstack-ai#4456 (2), objectstack-ai#4538 (2), objectstack-ai#4827, objectstack-ai#4829, objectstack-ai#4923 (5), objectstack-ai#5011, objectstack-ai#5122, objectstack-ai#5125, objectstack-ai#5126, objectstack-ai#5493 (3), objectstack-ai#5777, objectstack-ai#5817, objectstack-ai#5945 (4), objectstack-ai#6345 (2), objectstack-ai#6428, objectstack-ai#6430, objectstack-ai#6523, objectstack-ai#6775 (2), objectstack-ai#6776, objectstack-ai#7378 (3), objectstack-ai#7616 (2), objectstack-ai#8321, objectstack-ai#11122 (2), objectstack-ai#11741, objectstack-ai#11832, objectstack-ai#13700, objectstack-ai#13937, objectstack-ai#14103, objectstack-ai#14244, objectstack-ai#14384, objectstack-ai#14945, objectstack-ai#14969, objectstack-ai#15389, objectstack-ai#15429, objectstack-ai#16231, objectstack-ai#16495, objectstack-ai#16559, objectstack-ai#16693, objectstack-ai#16786, objectstack-ai#19620, objectstack-ai#20323, objectstack-ai#20390, objectstack-ai#20740, objectstack-ai#20935, objectstack-ai#20940, objectstack-ai#21005, objectstack-ai#21220, objectstack-ai#21458. - Each title already states the pinned decision. In `conversions/` most titles are the conversion entry's own id ("action-aria-removed", "app-hidden-to-unpublished"), which is the decision that landed. - **Qualified references** were read before dropping: "objectstack-ai#13937 shape 4" (keep the consume order and add an operator exit verb; the title already names the repairable run), "objectstack-ai#4923 house rule" (equal values dedupe, different values keep both; the title already says "keeps BOTH"), "objectstack-ai#4127 batch 3" (the ledger extends past the enum; the title says "beyond the enum"), "objectstack-ai#4251 B3" (`IObjectQLEngine` widens `IDataEngine`; the title says it), "objectstack-ai#5122 shape" (a wrapper that forwards only required members; the title says it), and "objectstack-ai#7378 row 1" / "row 3" (both refusal messages already state their row's ruling). - **The 404 records:** objectstack-ai#6345 (`e2798fa`: one driver vocabulary, `mongo` converged to `mongodb`), objectstack-ai#6523 (`aa4b90d`: enforcement takes the full `ExecutionContext`), objectstack-ai#11741 (`b706af9`: an optional `organizationId` on both email inputs), objectstack-ai#16559 (`c7aca0dce`: the resume failure declared once, carried by a success answer), objectstack-ai#16786 (`6059b29`: `updateById` declares the record or `null`). Each title already carries what landed. - **`(batch objectstack-ai#76)`** in `resume-failure-report.pin.test.ts:137` goes with `objectstack-ai#16559` in the same literal. It names the decision batch whose ruling the title already states ("the resume failure a success answer carries"). It is outside the gate's pattern, and it is declared here. - **`PD objectstack-ai#12`** stays in `conversions.test.ts:785`. It is a Prime Directive reference in AGENTS.md, not a tracker number, and the untouched sibling title at `:180` spells it the same way. ## The one id that stays `contracts/approval-service.test.ts:274` is `expect(doc).toContain('objectstack-ai#16495')`. It is not a title: it is the expected value of an assertion that reads the `continueRestoredRun` docblock in `contracts/approval-service.ts` and pins that the docblock names the sibling it was ruled to copy. Changing it needs a code comment and assertion logic, which this claim excludes. It moves with that docblock when the comment lane rewrites `approval-service.ts:999`. ## 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. Neither directory has `__snapshots__`, and no `.snap` file is tracked under `packages/spec`. - **Projects:** none of the 34 files is listed in `packages/spec/vitest.repo-tests.json`, so all run in the `local` project. - **By substring:** every old literal, plus a window around each id (241 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 17 needle hits land on 11 lines: - Sibling test strings in other lanes' packages: `packages/objectql/src/metadata-service-roundtrip-conformance.test.ts:232` (the objectql driver of the same table, `register must REFUSE this write (objectstack-ai#7378)`); `packages/plugins/plugin-security/src/get-queryable-fields.test.ts:136` and `:165` (`[objectstack-ai#20935]`), `resolve-permission-sets-for-context.pin.test.ts:103` (`[objectstack-ai#7616]`), `authored-row-write-verdict.test.ts:350` (`[objectstack-ai#5493]`); and `packages/metadata-core/src/artifact-forward-conversion.test.ts:277` (`(ADR-0113, objectstack-ai#16693)`). - Code comments: `packages/spec/src/contracts/automation-service.ts:1061` (`objectstack-ai#13937 shape 4`), `packages/spec/src/contracts/index.ts:44` (`(objectstack-ai#4127)`), `packages/cli/src/utils/view-container-names.ts:11` and `packages/objectql/src/view-container-name-refusal.ts:13` (`objectstack-ai#7378 row 1`). ## 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 the three in `metadata-service-roundtrip-conformance.test.ts`: the reference double's two refusal messages (`:68`, `:75`) and the `expect` message at `:142`. No test asserts on the dropped text: the refusal checks assert `code`, `status` and the write's coordinates. - **Result:** 33 of 34 files SAME on all three legs. `conversions.test.ts` passes the skeleton and comment legs and is flagged on one string, `:785`, because its rewritten title keeps `PD objectstack-ai#12`. That was predicted in writing before the run. With `PD objectstack-ai#12` spelled `PD-12` in both the base and head copies of that one line, the file reads SAME with 19 changed titles. - **Totals:** 96 changed literals, 93 titles and 3 declared. The diff's `+` lines are exactly the 96 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 `866b4393d0`) and at the head, in the `local` project. Both sides read 595 / 595 passed, with the same count and status sequence per file in 34 of 34. 354 full test names change, and each equals the base name with the planned replacements applied. One full name repeats on both sides: two `sqlite` rows of the `stored.test.ts` `it.each` table share the `%s` name. That predates this PR. ## 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/shared/expression.zod.ts` and `dist/contracts/index.js` are in it. - In `dist/`, four new phrases and three old ones 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 `72513933ee`) - `pnpm turbo run build` over all packages: 71 / 71. - `@objectstack/spec`: - `vitest run --project local`: 614 files, 18285 passed, 1 todo. - `typecheck` exit 0, including `check:test-typecheck`. Its program holds all 34 touched files, counted with `tsc --listFilesOnly`. - **Gates:** `dispatch-gates --commands` derived 79 families (stage 12's 80 without `check:future-spec-major`, which no touched file feeds), 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, 0 errors and 0 warnings. The population comes from ESLint's own config: 34 configured, 0 ignored. No `parserOptions.project` or `projectService`, so no untouched file's verdict can move. - `check-governed-merges --test`: NOT governed, 192 changed lines. ## Acceptance notes - **The `objectstack-ai#16495` needle** at `approval-service.test.ts:274` stays, with the `approval-service.ts:999` docblock it pins. The comment lane owns both. - **Code comments still carry ids** in these 34 files and in the two directories' sources. Comments are not this card's share and are untouched here. - **Sibling test strings in other packages** repeat ids this PR dropped: the objectql driver of the round-trip table, three `plugin-security` test files and one `metadata-core` test (listed under Readers). Each is its own lane's test-string stage. - **`origin/main` moved** five commits past the base before this PR opened (objectstack-ai#21752, objectstack-ai#21754, objectstack-ai#21753, objectstack-ai#21751, objectstack-ai#21743). None touches `packages/spec`, so nothing was merged. objectstack-ai#21756, which also edits `contracts/security-service.test.ts`, has no PR yet; whichever lands later merges `origin/main`. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21731
Clause-②: no
A TOTP enrollment now names the deployment, not the auth library.
/two-factor/enableand/two-factor/get-totp-uriansweredotpauth://totp/Better%20Auth:...&issuer=Better+Auth. They now carry the deployment's app name:OS_APP_NAME, else the configured app name, elseObjectStack. An explicitly setbranding.workspace_namestill outranks it. Existing enrollments keep working, because the issuer is a display label beside an unchanged secret.What changed
@objectstack/plugin-auth.AuthManager.createAuthInstance()passesgetAppName()to better-auth as itsappName. That is the one app-name authority every auth email already reads. No second read was added, andtwoFactor({ issuer })is left unset (see the better-auth reading below).@objectstack/cli(producer side).objectstack serveconstructsAuthPluginwithappName: resolveDeploymentAppName(...).resolveDeploymentAppNameis the email capability's existing chain (OS_APP_NAME>config.email.appName>config.email.defaultTemplateContext.appName>config.appName>ObjectStack), factored out ofresolveEmailCapabilityArgunchanged. Both consumers now call that one resolver with the same arguments.OS_APP_NAMErow incontent/docs/deployment/environment-variables.mdxnow says what the variable names. It used to say "the OAuth app display name", which was never true..changeset/21731-totp-issuer-app-name.md,patchfor both packages. It says that an existing enrollment keeps working.Why the landing reaches
packages/cli: the triage premise was false underserveThe card and triage both assumed that
getAppName()honoursOS_APP_NAME. Underobjectstack serveit did not.servebuiltAuthPluginwith noappName, sogetAppName()answeredObjectStack. Auth emails hid this: their ownappNameoutranks the email service's template context (plugin-email/src/email-service.ts, the{ ...defaultTemplateContext, ...input.data }merge), and the template context was the only place that carriedOS_APP_NAME.These readings come from the public door, an
os devboot (pnpm dev:todo -- --fresh,OS_AUTH_TWO_FACTOR=true,OS_APP_NAME='Acme Ops', seeded admin, secrets redacted):POST /api/v1/auth/two-factor/enabletotpURIsys_email)serve.tsline ablated,auth-manager.tsfix in placeotpauth://totp/ObjectStack:admin%40objectos.ai?...&issuer=ObjectStackReset your ObjectStack passwordotpauth://totp/Acme%20Ops:admin%40objectos.ai?...&issuer=Acme+OpsWithout the
servehalf, the card's first pin (the issuer equalsOS_APP_NAME) fails at the public door. The same root cause also fixes the auth emails'{{appName}}underserve.better-auth 1.7.3, read off its dist
appNameis read once, at init:dist/context/create-context.mjs:131,appName: options.appName || "Better Auth".dist/plugins/two-factor/index.mjs:170(enable):issuer || options?.issuer || ctx.context.appName. The first term is the request body's ownissuer.dist/plugins/two-factor/totp/index.mjs:133(get-totp-uri):options?.issuer || ctx.context.appName. HereoptionsistotpOptions, and its type omitsissuer(dist/plugins/two-factor/types.d.mts:25declarestotpOptionsasTOTPOptionswithissueromitted).appNameexists inbetter-auth,@better-auth/core,sso,scim,oauth-provider,telemetryorkysely-adapter.corehas 3 hits and all of them are.d.mtsdocs. The other packages have 0."better-auth"(dist/cookies/index.mjs:28). The core type doc says the prefix defaults fromappName, but in 1.7.3 it does not. better-auth reads noAPP_NAMEenv either.appNameis the one typed key that names both routes.twoFactor({ issuer })alone would leaveget-totp-urionBetter Auth, so the triage condition for setting it explicitly does not hold.Liveness: what follows a branding change
OS_BRANDING_WORKSPACE_NAME='Env Brand'andOS_APP_NAME='Acme Ops', first enableissuer=Env+Brand: the boot-time branding value reaches the issuerPUT /api/settings/branding {"workspace_name":"Renamed Co"}(200), then enable and get-totp-uriissuer=Acme+OpsReset your Renamed Co passwordctx.context.appNameis fixed when the better-auth instance is built. A rename after that reaches the issuer at the next rebuild (a restart, or an auth-settings change that callsapplyConfigPatch). Auth emails follow it on the next send. No route inauth-manager.tsfollows the live value on both TOTP routes without rebuilding the instance: a getter-backedtwoFactor({ issuer })would follow it onenableonly. The divergence is recorded in the report's open question for the PM, and the code comment and changeset state it.Tests (HEAD
e7e32f4f)packages/plugins/plugin-auth/src/two-factor-totp-issuer.test.ts(new, real better-auth pipeline throughAuthManager.handleRequest, on the in-memory engine the sibling 2FA suites use):enable, andObjectStackwhen unset;get-totp-urinames the same issuer for the same secret;Better Authlabel and confirmed is then served by a process configured asAcme Opsover the same store. The codes from the OLD URI's secret complete a sign-in challenge, andget-totp-urireturns the same secret relabelled. The stored row does not contain the label.packages/cli/src/commands/serve-auth-app-name.contract.test.ts(new, unit tier):resolveDeploymentAppNameequals the email chain's answer on all five rungs;serve.tsfinds exactly onenew AuthPlugin(call, passingappName: resolveDeploymentAppName(...)with the same argument text as the oneresolveEmailCapabilityArg(...)call.pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: 119 files, 2509 passed, 10 skipped.pnpm --filter @objectstack/plugin-auth typecheck: exit 0. It includescheck:test-typecheck, and the new file compiles clean, with no ledger entry.pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2: 258 files, 3777 passed. The integration tier is left to CI, because the diff touches no spawn entry and no integration-tier file.pnpm --filter @objectstack/cli typecheck: exit 0.Ablations (each through
scripts/ablation-replace.mjs, restore proven blob == HEAD)auth-manager.tswithout theappNameline: all 5 new plugin-auth tests turn red, for exampleexpected { labelIssuer: 'Better Auth', ... } to match object { labelIssuer: 'Acme Ops', ... }. Restored to bloba6696db0, which equals HEAD.serve.tswithout theAuthPluginappNameline: the construction scan turns red (AuthPlugin must receive appName from resolveDeploymentAppName: expected +0 to be 1). Restored to blob121c8125, which equals HEAD.serve.tsmutation was rebuilt intopackages/cli/distand booted (ablation-dist-preflight --absentpassed). The result is theObjectStackrow in the first table. The restore leg rebuiltdistand the preflight found the marker present again, with a clean tree.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 99 commands from this branch, and every one exited 0.--ranreports: 99 derived, 99 run, 0 NOT-MEASURED, 0 UNRUN. ESLint (--no-inline-config) on the 4 changed TS files reports 0 errors and 0 warnings. That run covers only the changed files.eslint.config.mjslints**/*.{ts,...}and never enables type-aware linting, so this diff cannot move a verdict on an untouched file.Acceptance notes
AuthPluginOptionsdoes not declareappName, althoughAuthPluginforwards it toAuthManagerby spread.serveimports the plugin through a variable specifier, so the call is untyped. A typed host writingnew AuthPlugin({ appName })gets a loud excess-property error, not a silent drop. Declaring it would widen a published type, so it is left out of this PR, which isClause-②: no. Carrier: none.appName, as its type doc already claims, theappNamepassed here would rename session cookies on upgrade. Re-checkdist/cookieswhen bumping. Carrier: whoever bumps better-auth.plugin-devand theverifyharness constructAuthPluginwithoutappName. Neither readsOS_APP_NAMEanywhere, so no surface there disagrees with another. Noted only./two-factor/enableaccepts a request-bodyissuer(vendor behaviour, first term above). It only relabels the caller's own enrollment and is display-only. Noted only.Generated by Claude Code