From 79ebb376651ce769e9b3ecc450ef033f04d922a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 09:23:14 +0000 Subject: [PATCH] =?UTF-8?q?docs(qa):=20scoped=20coverage=20sweep=20of=20sc?= =?UTF-8?q?an=20functionality=20(=E6=89=AB=E6=8F=8F=E5=8A=9F=E8=83=BD)=20?= =?UTF-8?q?=E2=80=94=2014=20items,=203=20revisions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoped checklist-author run: three read-only gap hunters (kernel security scanner · TOTP QR enrollment · every other scan-shaped surface) diffed the platform's scan functionality against the ledger; five per-area writers authored what was missing, grounding every clause in source. New items: identity-auth two-factor enrollment lifecycle (enrollment-reveal, verify-to-activate, backup-codes, disable-lifecycle); cli doctor-health-report, doctor-deprecation-scan, migrate-duplicates-inventory, datasource-introspect-codegen, hook-body-extraction-gates, lint-severity-exit-contract; integration-system external-schema-introspection, external-schema-drift-gate; attachments-storage field-accept-maxsize-server-enforced (+ qa-media-constraints area recipe); platform-core interrupted-migration-boot-report. Revisions: auth-method-matrix (cite the 2FA lockout pin), migrate-plan-apply-json (nine subcommands, not eight), datasource-admin-lifecycle (stale knownGap — the federation service is now wired unconditionally by serve.ts). coverage.json: mapped the new items into the datasource/field/hook kinds (still 30 kinds mapped, 0 waived). FOLLOW-UPS.md §7 records what is NOT an item: the inert PluginSecurityScanner family (ADR-0049 enforce-or-remove), docs drift (phantom contentProcessing virus scanning, dead codemod prescription, eager-FS-scan claim), defects D9–D15, and the kernel/cloud spec blind spot in the coverage ratchet. Validated: node scripts/checklist-select.mjs --self-test (17 pass) + node scripts/check-platform-checklist.mjs (OK — 15 areas, 204 items, 30 kinds mapped, 0 waived). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MjcmqkgzLbc6WyDQ6gSuU3 --- docs/qa/platform-checklist/FOLLOW-UPS.md | 98 ++++ .../areas/attachments-storage.json | 133 +++++ docs/qa/platform-checklist/areas/cli.json | 550 +++++++++++++++++- .../areas/identity-auth.json | 356 +++++++++++- .../areas/integration-system.json | 218 ++++++- .../areas/platform-core.json | 85 +++ docs/qa/platform-checklist/coverage.json | 10 +- 7 files changed, 1431 insertions(+), 19 deletions(-) diff --git a/docs/qa/platform-checklist/FOLLOW-UPS.md b/docs/qa/platform-checklist/FOLLOW-UPS.md index 3d8dc3187a..5abe552efd 100644 --- a/docs/qa/platform-checklist/FOLLOW-UPS.md +++ b/docs/qa/platform-checklist/FOLLOW-UPS.md @@ -166,3 +166,101 @@ base is a showcase design call with consequences beyond this item — it is also contrast side `access-security.readonly-package-locks-studio` needs — so it is filed separately rather than guessed at. The item keeps its `blocked(fixture)` and its `knownGap` untouched. + +## 7. Scoped sweep 2026-08-20 — 扫描功能 (scan functionality) + +Scoped question from the maintainer: does the platform's scan functionality have test +coverage? Three read-only hunters (kernel security scanner · TOTP QR enrollment · every +other scan-shaped surface) diffed against the ledger. The testable gaps were authored in +the same change (identity-auth two-factor lifecycle ×4; cli doctor/doctor-scan/migrate- +duplicates/datasource-introspect/hook-body-gates/lint ×6; integration-system +introspection + drift gate ×2; attachments-storage server-side accept/maxSize; +platform-core interrupted-migration boot report — plus 3 stale-item revisions). What +follows is what is NOT a checklist item: inert surfaces, docs drift, defects, and one +governance hole. + +### 7a. Declared-but-inert scan surfaces (ADR-0049 enforce-or-remove candidates — none are testable, none got items) + +| surface | evidence | the deadness, precisely | +|---|---|---| +| `PluginSecurityScanner` (`packages/core/src/security/security-scanner.ts:43`) | zero constructors outside `packages/core/examples/`; not in plugin-loader, service-package, rest, or any CLI path | Exported dead code on the PUBLIC barrel (`packages/core/src/index.ts:28` re-exports `./security/index.js`). 3 of 5 scan methods are empty stubs; `scanDependencies` has a real loop whose only data source (`addVulnerability`, `:309`) has zero callers; `updateVulnerabilityDatabase` (`:344`) is a log-only no-op. | +| `KernelSecurityScanResult` / `KernelSecurityVulnerability` / `PluginSecurityManifest.scanResults` (`packages/spec/src/kernel/plugin-security-advanced.zod.ts:385,476,625`) | no `.parse`/`.safeParse` site anywhere; only consumer is the dead scanner (type-only import) | 22 rows published to `packages/spec/authorable-surface/kernel.json:286-310` with zero authors and zero parsers. The whole `plugin-security-advanced` module has no runtime consumer. | +| `PluginQualityMetrics.securityScan` (`packages/spec/src/kernel/plugin-registry.zod.ts:73-83`) | spec self-test only | Nothing reads or writes it at runtime. | +| Marketplace/incident scan vocab (`marketplace.zod.ts:348` 'scanning' status, `marketplace-admin.zod.ts:42,193`, `incident-response.zod.ts:39` 'malware') | declared-only enum members, no producer in this repo | Cloud/EE surface. Same shape as the `'failed'`/`'expired'` upload statuses #7667 had to close: declared, published, no writer. | +| MetadataPlugin FS scan + `metadata-fs` boot scan (`packages/metadata/src/plugin.ts:257,270` — `watch ?? false`; `packages/runtime/src/standalone-stack.ts:698-702` hard-off; `metadata-fs` unwired from any `os dev`/`os serve` lane) | unit-pinned in-package only | No reachable fixture from any shipped boot; if a future lane wires `metadata-fs`, the boot-scan/watcher dot-entry divergence is the risk to test first. | + +Compounding the first row: `packages/core/PHASE2_IMPLEMENTATION.md:266-311` advertises +the scanner as a working feature, tells readers to import from `@objectstack/core/security` +(a subpath `packages/core/package.json` does not export), and its sample fields +(`scanResult.passed`/`.score`/`.summary.critical`) do not exist on the actual schema — +the example (`examples/phase2-integration.ts`) sits outside every tsconfig and is never +typechecked. Enforce or remove; if removed, the spec-property-retirement playbook applies +to the authorable-surface rows. + +### 7b. Docs drift (PD#10 class — file as docs fixes, not checklist items) + +- **Phantom `contentProcessing` virus scanning** — `content/docs/protocol/objectql/types.mdx:1067-1069` + and `:1086` promise "thumbnail generation, virus scanning … under `contentProcessing`"; + `content/docs/data-modeling/validation-rules.mdx:302` redirects file-storage virus + scanning to a connector setting. `contentProcessing` exists in exactly those doc lines — + no schema key, no code, and no content inspection of any kind exists on the upload path + (mimeType is trusted verbatim from the client body, `storage-routes.ts:241-243`). Both + docs should say plainly the platform performs no upload-time content inspection; the new + `attachments-storage.field-accept-maxsize-server-enforced` item records the same boundary + on the QA side. +- **Dead remediation prescription in a live command** — `doctor.ts:2149` prints "Run + `objectstack codemod v2-to-v3` to auto-fix"; no `codemod` command exists (the real path + is `os migrate meta`), and `content/docs/protocol/backward-compatibility.mdx:134` admits + it. Fix the string; the new `cli.doctor-deprecation-scan` item carries the expected-fail + probe until then. +- **`metadata-service.mdx:188`** presents `eager` bootstrap as "Scans filesystem … at + boot (default)"; no shipped boot path scans (`watch` defaults false, `os dev` disables it + explicitly). Stop advertising the scan as default behavior. +- **`admin-routes.ts:518` comment** claims a Studio "sync objects" consumer for + `/remote-tables`; no such consumer exists in objectui/app-shell — the live callers are + the two `os datasource` commands. Cleanup comment fix. + +### 7c. Defects found while grounding (auth-integrity rows: maintainer decision before any public issue) + +| # | defect | evidence | captured in | sensitivity | +|---|---|---|---|---| +| D9 | `sys_two_factor.verified` declares `defaultValue: true` while better-auth enrols `verified: false` and `AUTH_TWO_FACTOR_SCHEMA` does not map `verified` — if better-auth omits the column on insert, the ObjectQL default marks an unverified enrolment active | `sys-two-factor.object.ts:166-170`; `auth-schema-config.ts:366-374` | identity-auth.two-factor-verify-to-activate (observe-and-flag probe) | **auth-integrity — do not file publicly without maintainer** | +| D10 | `sys_user.generate_backup_codes` has no `resultDialog` — on the only navigable surface the user regenerates codes they are never shown; permanent-lockout path | `sys-user.object.ts:435-452` | identity-auth.two-factor-backup-codes (observe-and-flag) | UX-integrity/auth — maintainer call | +| D11 | `POST /api/v1/auth/two-factor/get-totp-uri` live re-reveal vs the reveal dialog's "shown only once" promise; endpoint absent from SDK ledger rows and targeted by no action | `auth-route-ledger.ts:374` vs `sys-two-factor.object.ts:76` | identity-auth.two-factor-enrollment-reveal (probe) | auth — maintainer call | +| D12 | `os doctor` false-PASS: `findMissingTests`/`findDeprecatedUsages` scan only `/packages/spec/src`, so in any user app doctor prints "✓ Test coverage"/"✓ Deprecations" about a tree it never examined | `doctor.ts:1141-1143,1159-1161` → `✓` at `:1939,:1951` | cli.doctor-health-report (expected-fail probe) | correctness — safe to file | +| D13 | `FileConstraintError` declares `code: 'ERR_FILE_CONSTRAINT'` but no `status`, and rest's `classifyDataError` has no branch for it — the server-side accept/maxSize refusal exits `/api/v1/data` as a sanitized **500 INTERNAL_ERROR** with the field-naming prose withheld from the body (the sibling `FileFieldBulkWriteError` docblock names `status: 400` as exactly what prevents this; same class as #7525) | `file-reference-lifecycle.ts:168-173,181-191`; `packages/rest` error-response classification | attachments-storage.field-accept-maxsize-server-enforced (wire-status recorded per run; a measured 500 is extracted as a finding, not scored as an enforcement fail) | correctness/wire-contract — safe to file | +| D14 | `MigrationRecoveryPlugin` is composed by NO boot path — `serve.ts` auto-registers `PlatformObjectsPlugin` but never the recovery plugin; standalone-stack, default-host, the showcase config, and the migrate CLI boot all omit it; only its unit test instantiates it. Interrupted-migration detection therefore never runs on any shipped boot, while `sys-migration-journal.object.ts:56-58` argues recovery must need "zero host wiring" | `packages/runtime/src/index.ts:58` (exported); `serve.ts:2073-2098` (what IS auto-registered) | platform-core.interrupted-migration-boot-report (fixtures an explicit registration; knownGap names the composition hole) | correctness/composition — safe to file | +| D15 | `extract-hook-body.ts:14-18`'s header promises "the build fails… no silent fallback" on a forbidden pattern, but the DEFAULT `os build` catches every extraction error and silently falls back to the .mjs bundle (`lower-callables.ts:63-78`), printing the warnings nowhere; only `--strict-body` (`compile.ts:126-149`) produces the worded refusals with exit 1. `hook-bodies.mdx:256` documents the warn-and-bundle default, so code comment and docs disagree with each other | `extract-hook-body.ts:14-18` vs `lower-callables.ts:63-78`, `compile.ts:126-149` | cli.hook-body-extraction-gates (default-path silent-fallback encoded as expected-fail contradiction clause) | correctness — safe to file | + +Two design notes captured inside items rather than as defect rows: `sys_user.mfa_required_at` +is stamped lazily and never cleared anywhere in source, so post-disable re-gating branches on +a pre-existing stamp (identity-auth.two-factor-disable-lifecycle, design-note clause); and +`datasource.checkOnBoot` (spec `datasource.zod.ts:313`, default true, liveness-ledgered live) +is read by NO runtime code — the drift scan always runs — a declared≠enforced ADR-0049 shape +encoded as a finding clause in integration-system.external-schema-drift-gate and a liveness +ledger correction candidate. + +### 7d. Governance hole the ratchet cannot see + +`coverage.json`'s kind universe derives from `packages/spec/liveness/*.json` — which has +no `kernel`, `plugin`, `marketplace`, or `incident` kind. The entire +`packages/spec/src/kernel/**` and `packages/spec/src/cloud/**` surface can grow, publish +to `content/docs/references/` (e.g. `references/kernel/plugin-security-advanced.mdx:17` +ships "Security scanning and verification" as a documented capability, auto-generated and +banner-marked but backed by nothing), and never register as an unmapped kind. Decide: +either those spec families join the liveness-governed set, or the ratchet's blind spot is +recorded as accepted scope. Until then, only a sweep like this one can catch it. + +### 7e. Checked and CLEAN (so the next sweep does not re-derive) + +- qrcode field type: scanning keys (`barcodeFormat`/`qrErrorCorrection`/`displayValue`/`allowScanning`) + pruned 2026-06, correctly dead (`field.zod.ts:1113-1119`); rendering covered by + `records-forms.field-type-matrix`; residue `suggestions.zod.ts:168` is a live + author-time typo alias (`barcode`→`qrcode`), not behavior. +- No content sniffing on upload: not a capability — boundary recorded in the new + attachments item, not a gap. +- `knowledge.mdx:38-39` PDF/scan extraction: an explicit protocol non-goal, no promise. +- `packages/verify` conformance `scan`: internal proof-attribution bookkeeping. +- 2FA challenge gate + lockout: covered (`identity-auth.auth-method-matrix` + + `two-factor-lockout.dogfood.test.ts`); endpoint existence pinned by + `auth-route-ledger.conformance.test.ts`. diff --git a/docs/qa/platform-checklist/areas/attachments-storage.json b/docs/qa/platform-checklist/areas/attachments-storage.json index 0c199ba436..a9a90732de 100644 --- a/docs/qa/platform-checklist/areas/attachments-storage.json +++ b/docs/qa/platform-checklist/areas/attachments-storage.json @@ -61,6 +61,37 @@ "The SDK helper `meta.saveItem(type, name, item)` does NOT send the `?package=` query — it PUTs the bare path (packages/client/src/index.ts:701-707). Steps 2-4 must therefore be issued as raw HTTP with the query string appended, or the scratch objects land outside the package and the teardown above will not take them with it.", "Field-level detail of the two personas' permission sets (which verbs each set grants) is recorded here as intent, not as a verbatim payload: run #7635 provisioned them through a runtime permission-set/position binding whose exact set body was not captured in the run record. Replay step 6 by the stated OUTCOME (read-not-edit for A, baseline-only for B) and verify it directly, rather than trusting a payload nobody pinned." ] + }, + "qa-media-constraints": { + "title": "Scratch object qa_media carrying FIELD-level accept/maxSize (plus an extension-only accept field)", + "why": "The showcase declares accept/maxSize ONLY on action params (examples/app-showcase/src/ui/actions/index.ts:334,337 — the dialog-widget/ADR-0059 upload-guard lane), never on an object FIELD, so the server-side record-write re-enforcement (field-accept-maxsize-server-enforced) has no stock target: without this recipe every clause of that item is blocked(fixture) on every run. Landing one constrained field in the showcase seeds proper would retire this recipe.", + "provenance": "authored from source in the 2026-08-20 scoped scan-functionality sweep (claude/new-session-0pv25p); call shapes copied from qa-scratch-authz (#7670), constraint keys from the FieldSchema declaration — not yet proven by a live run", + "app": "showcase", + "requires": [ + "an admin session that holds the `manage_metadata` capability — same gate as qa-scratch-authz (PUT /api/v1/meta/:type/:name is capability-gated per ADR-0066 D1, packages/rest/src/rest-route-ledger.ts)", + "an isolated boot (own port + file DB, dogfood skill §0) — this recipe authors metadata into the running app" + ], + "sequence": [ + { + "step": 1, + "call": "POST /api/v1/packages", + "body": { "manifest": { "id": "com.objectstack.qa.media", "name": "QA media-constraints fixture", "version": "1.0.0", "type": "app" }, "enableOnInstall": true }, + "expect": "2xx with the installed package echoed back. The id is deliberately DISTINCT from qa-scratch-authz's com.objectstack.qa.attachments so the two recipes install and tear down independently. Re-run against a live DB 409s on the duplicate id — send `overwrite: true` deliberately, never by reflex.", + "source": "same grounding as qa-scratch-authz step 1: packages/rest/src/rest-route-ledger.ts:312 note; body shape pinned in packages/client/src/client.test.ts:2144-2166" + }, + { + "step": 2, + "call": "PUT /api/v1/meta/objects/qa_media?package=com.objectstack.qa.media", + "body": { "name": "qa_media", "label": "QA Media", "sharingModel": "public_read_write", "fields": { "name": { "type": "text", "label": "Name", "required": true }, "poster": { "type": "image", "label": "Poster", "accept": ["image/png", "image/jpeg"], "maxSize": 1048576 }, "doc": { "type": "file", "label": "Doc", "accept": [".pdf"] } } }, + "expect": "2xx. `poster` is the MIME-entry + maxSize probe; `doc` (accept = ['.pdf'] ONLY, no maxSize) is the extension-entry probe whose dotless-filename hole is a documented boundary of the enforcement. `enable.files` is deliberately ABSENT: field-owned files ride file-reference-lifecycle.ts (activeFileFields keys on file-class field types), not the #2727 sys_attachment opt-in gate, so the object needs no attachments enablement.", + "source": "accept/maxSize are declared FieldSchema keys since ADR-0104 D3 wave 2 (packages/spec/src/data/field.zod.ts:876-883 — 'Offered to the file picker AND enforced on write'); authoring-call shape identical to qa-scratch-authz step 2 (meta.ts:262,319 for ?package=); file-class field set is FILE_REFERENCE_TYPES = image/file/avatar/video/audio (packages/spec/src/data/field-value.zod.ts:146-148)" + } + ], + "teardown": "DELETE /api/v1/packages/com.objectstack.qa.media — or discard the isolated file DB, the cheaper path an isolated boot makes free.", + "knownGaps": [ + "Same SDK sharp edge as qa-scratch-authz: `meta.saveItem` drops `?package=` (packages/client/src/index.ts:701-707) — issue step 2 as raw HTTP or the object lands outside the package and the teardown misses it.", + "Unlike qa-scratch-authz this recipe has NOT yet been proven by a live run — it is derived from the same authoring path the proven recipe uses plus the FieldSchema declaration. If step 2 4xxs on the constraint keys, re-read field.zod.ts:876-883 before assuming the recipe rotted; a parse-time strip of accept/maxSize would itself be a finding (the pre-#4001 silent-strip class)." + ] } }, "items": [ @@ -698,6 +729,108 @@ { "revision": 1, "date": "2026-08-07", "change": "new item transcribed from the live e2e pin (grid-file-upload.spec.ts) with seeded names verified (Widget A / showcase_invoice_line.receipt); ADR-0059 form-side guard cross-referenced to records-forms instead of duplicated", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-08-11", "change": "clause 3 restated against ADR-0104 D3 after run #7635 scored it `partial` on a product that is CORRECT: the clause demanded a resolved stored-file object with receipt.name and an absolute http(s) url, but D3 makes the stored form the bare opaque sys_file id and the resolved shape the read/expand form only — so the item was asserting a contract the ADR had already retired from the write path. The clause now asserts the opaque id plus the field-ownership stamp (ref_object/ref_id/ref_field/acl) that proves the reference is managed rather than a placeholder, the step text and title follow, a negative pins the drift itself so a future run does not re-score the shipped shape as a defect, and source cites the ADR + the storage-routes.ts line documenting the id. Checklist mirrors the product; no product change", "ref": "#7669" } ] + }, + { + "id": "attachments-storage.field-accept-maxsize-server-enforced", + "title": "A field's declared accept/maxSize is re-enforced SERVER-SIDE on record write — the control the client widget check explicitly is not — with the declared missing-metadata and dotless-extension holes scored as documented boundaries", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["seeded admin (admin@objectos.ai) — the check is a declaration constraint, not an authz gate: it binds every caller identically, so no restricted persona is needed"], + "fixtures": { + "app": "showcase", + "requires": [ + "the qa_media scratch object with field-level accept/maxSize (see provisioning) — no stock showcase object declares either", + "the storage service live at the default base — presign/complete is how the probe sys_file rows are minted, and their mime_type/size are the DECLARED values the check reads (storage-routes.ts:241-243,255)", + "a system-write channel to CLEAR mime_type and size on one committed sys_file row for the missing-metadata clause — the presign door refuses a body without mimeType/size (400 INVALID_REQUEST, storage-routes.ts:242-243), so a metadata-less row cannot be minted through the API; without the system write that one clause is blocked(fixture)" + ], + "provisioning": { + "use": "qa-media-constraints", + "why": "every clause of this item runs against qa_media.poster (MIME accept + maxSize) and qa_media.doc (extension-only accept). Without the recipe the ENTIRE item is blocked(fixture): the showcase's only accept/maxSize declarations sit on action params, which never reach this seam." + }, + "knownGaps": [ + "FIXTURE GAP (why the recipe exists): the showcase declares accept/maxSize only on ACTION PARAMS (examples/app-showcase/src/ui/actions/index.ts:334,337) — that lane feeds the dialog widget and the ADR-0059 Confirm-while-uploading guard pinned by records-forms.upload-guard-blocks-confirm, and never reaches assertFileConstraints, which fires on OBJECT-FIELD references at record write. No showcase object field carries either key, so on stock seeds this item cannot run at all. Landing one constrained field in the showcase seeds would retire the qa-media-constraints recipe.", + "CONTENT-SNIFFING BOUNDARY (record so runs do not score it as a leak): enforcement is DECLARATION-based, never content-based. sys_file.mime_type is taken verbatim from the client's own request body on both upload doors — presigned (storage-routes.ts:241-243 destructure, :255 persisted, :267 even echoed back as the upload content-type header) and chunked (:342-344, :359) — and nothing on the upload path sniffs magic bytes (no file-type/sniffing code exists in service-storage; measured by search, only fs.readdir withFileTypes matches). A caller may therefore upload arbitrary bytes while declaring image/png and pass every accept test. That is the boundary of what the platform claims; a run demonstrating it records a documented-boundary observation, never a FAIL of this item.", + "WIRE-ENVELOPE (per source, unmeasured — the run must record what it sees): FileConstraintError declares `code: 'ERR_FILE_CONSTRAINT'` (registered, packages/spec/src/api/error-code-ledger.zod.ts:320) but NO `status`/`statusCode` (file-reference-lifecycle.ts:168-173), and rest's classifyDataError has no ERR_FILE_CONSTRAINT branch — so the declared-status passthrough never fires and the refusal should exit the /api/v1/data door through the sanitized 500 INTERNAL_ERROR terminal (packages/rest/src/error-response.ts UNCLASSIFIED_FAULT), the field/accept prose reaching the server LOG rather than the body. Its sibling FileFieldBulkWriteError documents `status: 400` as exactly what prevents that promotion (file-reference-lifecycle.ts:181-191), and #7525 fixed the same shape for statusCode-declaring hooks. Consequence for scoring: the enforcement clauses pass on the NO-ROW oracle regardless of envelope; a measured sanitized 500 is a wire-contract finding to EXTRACT as its own card, and a measured 4xx carrying ERR_FILE_CONSTRAINT means the product improved — revise clause 2." + ] + }, + "steps": [ + "replay the qa-media-constraints recipe (or verify qa_media already exists from this boot)", + "as admin, mint probe files via presign → PUT bytes → complete: (A) a small PDF declared mimeType 'application/pdf', size ~10KB; (B) a file declared mimeType 'image/png' with size 2097152 (> poster's 1048576 maxSize — size is the DECLARED value; the actual bytes need not be that large); (C) a compliant control declared 'image/png', size ~10KB; (D) filename 'README' (no dot), any mimeType; (E) filename 'report.txt', mimeType 'text/plain'", + "POST /api/v1/data/qa_media { name: 'accept-probe', poster: } and capture the refusal; list qa_media where name='accept-probe' → must be empty; read sys_file A in stored form → ref_id must still be null (the throw lands before the claim)", + "POST /api/v1/data/qa_media { name: 'size-probe', poster: } and capture the refusal + the empty re-list", + "POST /api/v1/data/qa_media { name: 'control', poster: } → 2xx; read sys_file C: ref_object='qa_media', ref_field='poster', ref_id=", + "update path: PUT/PATCH the 'control' record swapping poster to → capture the refusal; re-read the record → poster unchanged (the check also rides beforeUpdate, file-reference-lifecycle.ts:676)", + "capture the server log around each refusal: the FileConstraintError text names the field and the accept list ('not permitted by the accept list declared for...') or the byte counts ('exceeds the maximum size declared for...'); record the WIRE status + code each refusal actually surfaced with", + "documented hole 1 (missing metadata): system-write one committed sys_file clearing mime_type AND size to null, then POST it into poster → the write SUCCEEDS (file-reference-lifecycle.ts:255-257 — 'missing metadata is not evidence of a violation'; the maxSize guard at :267 and the mime read at :277 both require the value to be present)", + "documented hole 2 (extension-only accept vs dotless name): POST { doc: } → SUCCEEDS — accept ['.pdf'] against a name with no dot leaves testable empty and the check exits before judging (:285-289); contrast POST { doc: } → REFUSED ('report.txt' HAS an extension, so the .pdf entry is testable and mismatches, :238) — the contrast proves the hole is the dotless case, not a dead extension arm", + "boundary demonstration (optional): upload PDF bytes declared as image/png within maxSize → POST into poster → passes; record as documented-boundary, per knownGaps" + ], + "acceptance": [ + { + "clause": "a direct API write referencing a committed sys_file whose mime_type violates the field's declared accept is REFUSED and persists nothing: no qa_media row, and the sys_file row stays unclaimed (ref_id null) — the widget check is 'a convenience rather than a control — any caller talking to the API directly bypasses it' (file-reference-lifecycle.ts:247-249), and this server re-check is the control", + "oracle": "api", + "verify": "the POST answers non-2xx; a scoped re-list of qa_media returns zero rows; the stored-form sys_file read shows ref_id still null", + "evidence": "the refusal body + the empty list + the sys_file read" + }, + { + "clause": "a reference whose sys_file size exceeds the field's declared maxSize is refused identically, no row written (assertFileConstraints, file-reference-lifecycle.ts:267-274)", + "oracle": "api", + "verify": "same triple as clause 0 for the oversized probe", + "evidence": "the refusal body + the empty list" + }, + { + "clause": "the refusal is attributable to the constraint: the server log carries the FileConstraintError prose naming the FIELD and the accept list (or the declared byte counts), and the run RECORDS the wire status/code it measured. Per source the expected current wire shape is the sanitized 500 INTERNAL_ERROR with the prose withheld from the body (ERR_FILE_CONSTRAINT is ledgered but the error declares no HTTP status and classifyDataError has no branch for it — see knownGaps): that measured 500 does NOT fail this clause; it is extracted as a wire-contract card. A 4xx carrying ERR_FILE_CONSTRAINT and the prose means the envelope improved — revise this clause with the measurement", + "oracle": "log", + "verify": "log excerpt around the refusal shows the constraint message ('not permitted by the accept list declared for' / 'exceeds the maximum size declared for') naming the field; the run record states the measured HTTP status + body code beside it", + "evidence": "log excerpt + the measured wire status/code" + }, + { + "clause": "the UPDATE path is equally gated: swapping a compliant record's poster to the violating fileId is refused and the stored value is unchanged (applyCopyOnClaim runs in beforeUpdate too, file-reference-lifecycle.ts:676)", + "oracle": "api", + "verify": "the update answers non-2xx and a re-read shows the original poster value", + "evidence": "the refusal + the re-read" + }, + { + "clause": "the compliant control passes and is CLAIMED: a file satisfying both declarations writes normally and its sys_file row is stamped ref_object='qa_media'/ref_field='poster'/ref_id= — without this positive side, every refusal above would be equally satisfied by a write path that is simply broken", + "oracle": "api", + "verify": "the control POST answers 2xx and the sys_file read shows the ownership stamp", + "evidence": "the 2xx + the stamped row read" + }, + { + "clause": "DOCUMENTED BOUNDARY, scored as a pass: a sys_file with NO mime_type cannot fail an accept test and one with NO size cannot fail maxSize — the metadata-less reference is ACCEPTED by design ('missing metadata is not evidence of a violation', file-reference-lifecycle.ts:255-257; guards at :267 and :277/:285-288 each require the datum to be present). A run tempted to score this as an enforcement leak is pointed here instead", + "oracle": "api", + "verify": "after the system write clears mime_type and size, the same POST that clause 0 saw refused now answers 2xx", + "evidence": "the cleared sys_file read + the 2xx" + }, + { + "clause": "DOCUMENTED BOUNDARY, scored as a pass, with its live contrast: an accept list of ONLY extension entries judged against a filename with no dot yields testable.length === 0 and the check exits without judging (file-reference-lifecycle.ts:285-289) — the dotless write into doc (accept ['.pdf']) SUCCEEDS by design, while the dotted mismatch ('report.txt') is refused, proving the extension arm itself is alive and only the dotless case is the declared hole", + "oracle": "api", + "verify": "POST with the dotless-name file answers 2xx; POST with the .txt-named file is refused with no row", + "evidence": "both responses + the one empty re-list" + } + ], + "negative": [ + "a qa_media row created holding the violating reference is THE fail this item exists for — it means the only guard is the client widget, the exact 'declared but not enforced' state ADR-0104 removes (file-reference-lifecycle.ts:251-253)", + "a refusal that still claimed the file (sys_file ref_id stamped despite the non-2xx) is a FAIL — the rejection must be authoritative, not cosmetic (same rule as attach-requires-parent-edit)", + "⛔ NOT failures: the missing-metadata pass (clause 5) and the dotless-extension pass (clause 6) — both are the source's own documented boundaries; and arbitrary BYTES passing under a compliant declared mimeType is the declaration-based content boundary in knownGaps, not a bypass", + "⛔ NOT a violation probe: a random/unknown id token in the field passes untouched BY DESIGN — an id matching no sys_file row is treated as an external/legacy value and skipped before any constraint is read (file-reference-lifecycle.ts:421-423); probe with a REAL committed sys_file or the run measures nothing" + ], + "traps": ["absence-inference", "wrong-panel"], + "automated": { "kind": "unit", "ref": "packages/services/service-storage/src/file-reference-lifecycle.test.ts ('accept / maxSize enforcement' describe, :906-1006) — HANDLER-DIRECT: drives the hook through a fake engine, covering the semantics of clauses 0/1/4/5/6 (rejection, per-entry vocabulary, missing-metadata pass, no-constraints pass) at the unit level. It is NOT evidence about the wired REST lane or the wire envelope (clause 2), and per the attach-requires-parent-edit clause-3 lesson a handler-direct green can coexist with different wired behaviour — the dogfood lane has NO pin for this seam, which is precisely why this item exists." }, + "source": [ + "packages/services/service-storage/src/file-reference-lifecycle.ts:243-257 (the rationale: the widget check is a convenience, the server re-check is the control; missing metadata is not evidence of a violation), :259-298 (assertFileConstraints — maxSize guard :267-274, testable filter + early return :276-289, accept mismatch throw :291-296), :229-241 (matchesAcceptEntry — exact MIME / type\\/* wildcard / .ext-against-NAME vocabulary), :421-427 (the check rides copy-on-claim in the before hooks — beforeInsert :622, beforeUpdate :676 — and unknown ids are skipped at :423)", + "packages/spec/src/data/field.zod.ts:876-883 (accept/maxSize declared on FieldSchema, 'Offered to the file picker AND enforced on write') + packages/spec/liveness/field.json (both keys `live` with this enforcement as evidence)", + "packages/services/service-storage/src/storage-routes.ts:241-243,:255,:267 (presigned) and :342-344,:359 (chunked) — mime_type/size persisted VERBATIM from the client body: the declaration-based boundary in knownGaps", + "packages/spec/src/api/error-code-ledger.zod.ts:320 (ERR_FILE_CONSTRAINT registered) vs file-reference-lifecycle.ts:168-173 (no status declared) vs packages/rest/src/error-response.ts classifyDataError (no branch, declared-status passthrough skipped, UNCLASSIFIED_FAULT terminal) — the clause-2 wire analysis; file-reference-lifecycle.ts:181-191 (FileFieldBulkWriteError's `status: 400` note is the in-module precedent for what a 4xx exit requires)", + "packages/services/service-storage/CHANGELOG.md 17.0.0-rc.0 (changeset fe67e34, ADR-0104 D3 wave 2 PR-5a) — grounds since: v17", + "records-forms.upload-guard-blocks-confirm owns the CLIENT half (ADR-0059 dialog guard over ACTION-PARAM accept/maxSize, examples/app-showcase/src/ui/actions/index.ts:334,337). Cross-reference, do not duplicate — that item never touches this record-write seam" + ], + "history": [ + { "revision": 1, "date": "2026-08-20", "change": "new item from the scoped scan-functionality sweep (扫描功能): the server-side re-enforcement of field accept/maxSize had NO coverage on either lane — the only pinned guard is the client dialog's (records-forms.upload-guard-blocks-confirm), which the enforcement source itself calls 'a convenience rather than a control'. Authored with the qa-media-constraints recipe (no stock showcase field declares either key), the two source-documented holes (missing metadata, dotless-name vs extension-only accept) as documented-boundary passes, the declaration-based content boundary recorded so runs do not score sniffing absence as a leak, and the per-source wire-envelope analysis (ERR_FILE_CONSTRAINT ledgered but status-less → sanitized 500 expected) carried as a knownGap for the run to measure rather than rediscover", "ref": "claude/new-session-0pv25p" } + ] } ] } diff --git a/docs/qa/platform-checklist/areas/cli.json b/docs/qa/platform-checklist/areas/cli.json index 86725c6b21..2b7555a767 100644 --- a/docs/qa/platform-checklist/areas/cli.json +++ b/docs/qa/platform-checklist/areas/cli.json @@ -187,7 +187,7 @@ "title": "os migrate: bare command is a read-only plan, apply is safe-by-default, re-runs are idempotent, and --json exits 0 on success (#4873)", "since": "v15", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "cli", "personas": ["operator (local shell)"], @@ -260,6 +260,7 @@ "apply", "resume", "recorded-by", + "duplicates (#8928 read-only cross-partition duplicate-identifier inventory — ALWAYS JSON on stdout with NO --json flag, so the #4873 --json sweep must not probe it with one; deep coverage at cli.migrate-duplicates-inventory)", "meta", "files-to-references", "summary-nulls", @@ -270,10 +271,12 @@ "source": [ "packages/cli/src/commands/migrate/index.ts + plan.ts (#2186 bare-command-is-plan; #3917 enforced never-mutates; occupancy warning) + apply.ts (--allow-destructive / --force / --yes / in_sync)", "packages/cli/src/utils/format.ts (CliExitCode narrows the emitJson exit slot — the #4873 fix, commit 83df2fd)", - "packages/cli/src/commands/migrate/ (the eight registered subcommands enumerated as variants)" + "packages/cli/src/commands/migrate/ (the nine registered subcommands enumerated as variants)", + "sibling item cli.migrate-duplicates-inventory (the duplicates subcommand's own contract — cross-referenced, not duplicated)" ], "history": [ - { "revision": 1, "date": "2026-08-07", "change": "new item: the migrate topic's read-only/apply/idempotency contract from plan.ts+apply.ts, with #4873 --json exit-code honesty as a load-bearing clause pinned to format.exit-code.test.ts and the subcommand set enumerated from src/commands/migrate/", "ref": "claude/platform-test-checklist-ocwugl" } + { "revision": 1, "date": "2026-08-07", "change": "new item: the migrate topic's read-only/apply/idempotency contract from plan.ts+apply.ts, with #4873 --json exit-code honesty as a load-bearing clause pinned to format.exit-code.test.ts and the subcommand set enumerated from src/commands/migrate/", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-08-20", "change": "scoped scan-functionality sweep: the enumeration-as-variants under-counted — `duplicates` (#8928, landed 2026-08-16) made it NINE registered subcommands in src/commands/migrate/, not the eight the source note claimed. Added the ninth as a variant pointing detailed coverage at the new cli.migrate-duplicates-inventory item, with the one property a sweep of THIS item must know: duplicates has no --json flag (its output is always the JSON report), so the #4873 exit-code sweep must not feed it one and read the oclif Nonexistent-flag 2 as a violation", "ref": "claude/new-session-0pv25p" } ] }, { @@ -826,6 +829,547 @@ }, { "revision": 2, "date": "2026-08-18", "change": "corrected the stale 'no seeded admin unless one is requested' assumption in both knownGaps. commands/dev.ts resolves seed-admin as flags['seed-admin'] ?? true and documents it 'Default: on', so a bare `objectstack dev` on an empty DB seeds admin@objectos.ai / admin123 and prints it in the banner. Clause 4's open question ('is there any identity a newcomer can sign in with?') has a definite answer, and leaving the gap as written told the runner not to assume the answer the tree already fixes (#9467 CF-6)", "ref": "#9386" } ] + }, + { + "id": "cli.doctor-health-report", + "title": "os doctor: every check emits an attributed row, an unparseable posture is an ERROR not a swallow, ledger rows print under every posture, the ✓ is withheld when the read was incomplete — and the two monorepo-anchored ✓s that bless an unexamined tree are the expected-fail", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "cli", + "personas": ["operator (local shell)"], + "fixtures": { + "app": "scaffold", + "requires": [ + "a scratch blank scaffold (`npx -y create-objectstack@latest qa-doctor -t blank` + install — the same provisioning cli.scaffold-first-run drives) as the USER-APP shaped tree; the monorepo-vs-user-app split is load-bearing for the expected-fail clause", + "a scratch copy of the config for the deliberate config findings (orphan view, circular lookup pair, unreferenced object) — breakage never touches shared fixtures", + "a writable .objectstack/installed-packages/ in the scratch tree, so an unreadable ledger entry can be staged per run" + ], + "knownGaps": [ + "no stock fixture ships a config with an orphan view / circular lookup / unused object, so the config-analysis rows must be staged; record the staged shapes in evidence rather than skipping the rows silently", + "the config-analysis block runs only `if (configExists())` (doctor.ts:1955) — a bare directory exercises the environment rows alone; run both shapes and label which produced which rows" + ] + }, + "steps": [ + "baseline: from the untouched scaffold run `os doctor; echo $?` and `os doctor -v; echo $?` — capture both full reports (the environment rows print first: Node.js, pnpm, TypeScript, Dependencies, @objectstack/spec, Git, Environment files, then the extended checks)", + "stage the config findings in the scratch copy: a view whose object does not exist (orphan), two objects whose lookup fields point at each other (circular), and one object nothing references (unused); run `os doctor` and capture the three warning rows — then restore and capture the three ✓ rows (Dependencies / Object usage / View integrity)", + "unparseable posture: `OS_TENANCY_POSTURE=isolatd os doctor; echo $?` — the Tenancy posture ERROR row must print naming the variable and the allowed set, the REST of the report must still run, and the exit must be 1 (#5382: report-not-refuse, resolved OUTSIDE the config-analysis try at doctor.ts:1752-1753, row pushed at :1891-1893)", + "env provenance: put the same bad posture in a .env FILE (not the shell) and re-run — the finding must still fire (#5387 dotenv cascade) and the Environment files row must attribute the value to the file that supplied it", + "ledger: write .objectstack/installed-packages/broken.json holding non-JSON; run `os doctor` with NO posture set (default `single`) and capture the Installed packages row; then with a valid config and OS_TENANCY_POSTURE=isolated, confirm the run does NOT print the 'Unique scope ✓' success line while that entry is unreadable", + "exit split: on a warnings-only run capture `echo $?` (expect 0, '⚠️ Environment is functional' summary); on the posture-error run expect 1 ('❌ Some critical issues found' + the collected fix lines)", + "the expected-fail pair: in the scaffold — which has NO packages/spec/src — capture verbatim the '✓ Test coverage All *.zod.ts files have matching tests' and '✓ Deprecations No @deprecated tags found' lines, and note the sibling '@objectstack/spec Not built' warning row from the same monorepo-anchored path family (doctor.ts:1837)" + ], + "acceptance": [ + { + "clause": "every environment check emits its own row — Node.js, pnpm, TypeScript, Dependencies, @objectstack/spec, Git, Environment files, Installed packages — and -v adds the fix/detail lines without changing the verdicts", + "oracle": "log", + "verify": "both transcripts carry one row per check through renderHealthCheckResult (doctor.ts:1918-1926); the -v run differs only by detail lines", + "evidence": "the paired transcripts" + }, + { + "clause": "#5387 env provenance: doctor resolves the same .env* cascade os serve reads, and the Environment files row names WHICH source supplied each value — the files are never silently merged into process.env", + "oracle": "log", + "verify": "the .env-staged posture still produces the finding, and the Environment files row attributes it to that file rather than the shell (environmentSourcesCheck at doctor.ts:1875; pinned by doctor-env-provenance.test.ts — cite its pass as the attribution oracle, drive the CLI for the row itself)", + "evidence": "the transcript + the .env file staged" + }, + { + "clause": "#5382 posture honesty: an unrecognized OS_TENANCY_POSTURE is an ERROR row naming the variable — never swallowed by the config-analysis catch as 'Could not load config for analysis' — the rest of the report still runs, and the summary owns exit 1", + "oracle": "log", + "verify": "the isolatd run prints the Tenancy posture error row, later report sections still print after it, and echo $? is 1 (posture resolved once at doctor.ts:1752-1753, outside every try; exit via the hasErrors summary at :2158-2163)", + "evidence": "transcript + exit code" + }, + { + "clause": "#5429 ledger independence: installed-package ledger rows print under EVERY posture including the default `single` — an unreadable entry under .objectstack/installed-packages/ is its own row whether or not a config loaded and whether or not any posture was set", + "oracle": "log", + "verify": "the broken.json run with no posture set carries the Installed packages failure row (readInstalledPackageEntries called unconditionally at doctor.ts:1910-1911; posture-independence pinned by doctor-ledger-posture-independence.test.ts + doctor-ledger-read-failure.test.ts)", + "evidence": "the default-posture transcript" + }, + { + "clause": "the unique-scope ✓ is WITHHELD when the ledger read was incomplete: under `isolated` with an unreadable ledger entry, the 'Unique scope' success line must NOT print — a ✓ over an unexamined ledger is a false PASS (#5412/#5413/#5644)", + "oracle": "log", + "verify": "the isolated+broken-ledger run prints no 'Unique scope … No unconfirmed installation-wide uniques' line (gated on ledgerReadingIsComplete at doctor.ts:2063-2065), while the same run with the entry removed prints it", + "evidence": "the two isolated-run transcripts diffed on that line" + }, + { + "clause": "config-analysis rows both ways: the staged orphan view, circular lookup and unused object each produce a warning row naming the offender, and the restored config produces the three ✓ rows", + "oracle": "log", + "verify": "detectCircularDependencies / findUnusedObjects / findOrphanViews rows (doctor.ts:1998-2080) name the staged shapes; the clean run prints 'Dependencies', 'Object usage', 'View integrity' successes", + "evidence": "the staged and clean transcripts" + }, + { + "clause": "exit contract: 1 exactly when any ERROR row exists; warnings alone exit 0 under the '⚠️ Environment is functional' summary — warnings never flip the exit", + "oracle": "log", + "verify": "echo $? per run matches the summary branch taken (process.exit(1) only under hasErrors, doctor.ts:2158-2169)", + "evidence": "exit codes paired with summary lines" + }, + { + "clause": "EXPECTED-FAIL (product defect — record actual behavior): findMissingTests and findDeprecatedUsages examine only /packages/spec/src (doctor.ts:1142-1143 and :1160-1161) and return [] in any user app, whereupon doctor prints '✓ Test coverage' (:1939) and '✓ Deprecations' (:1951) about a tree it never examined. By doctor's own withhold-the-✓ reasoning (the ledger clause above, :2049-2065) a ✓ over an unexamined tree is the FAIL: the ✓ must be withheld, scoped to the tree actually walked, or the check skipped with a named reason. Today it prints — the run records the verbatim lines as the failure evidence and extracts the defect card", + "oracle": "log", + "verify": "in the scaffold (no packages/spec/src anywhere) both ✓ lines print verbatim; the clause FAILS while they do, and flips to pass only when the tree ships a fix that withholds or scopes them", + "evidence": "the verbatim ✓ lines + an `ls packages/spec/src` refusal from the same cwd" + } + ], + "negative": [ + "an unrecognized posture reported only as 'Could not load config for analysis' with exit 0 is the exact #5382 regression the placement fix closed", + "a 'Unique scope ✓' printed while an installed-packages entry was unreadable is a false PASS — worse than a missing check, because it stops the operator looking further", + "warnings flipping the exit to 1, or an error row exiting 0, breaks every CI wrapper that gates on doctor", + "adjacent, same class as the expected-fail: the '@objectstack/spec Not built' WARNING in a user app (doctor.ts:1837 probes /packages/spec/dist) is monorepo-anchored noise — record it in the run notes; it is not this item's fail but belongs on the same defect card" + ], + "traps": ["absence-inference", "stale-dist"], + "source": [ + "packages/cli/src/commands/doctor.ts (class at :1712, flags -v/--scan-deprecations :1715-1718, run body :1720-2172; posture resolve :1752-1753 + row :1891-1893; env sources row :1875; ledger read :1910-1911; unique-scope withhold :2049-2065; findMissingTests :1141-1157; findDeprecatedUsages :1159-1181; summary exit :2158-2169)", + "packages/cli/src/commands/doctor-env-provenance.test.ts, doctor-tenancy-posture-report.test.ts, doctor-ledger-dir-authority.test.ts, doctor-ledger-posture-independence.test.ts, doctor-ledger-read-failure.test.ts, doctor-config-load-cause.test.ts, doctor-node-env-default.test.ts (the unit pins — they cover the row-building seams, none drives the CLI end-to-end, which is why this item carries no automated entry)", + "sibling item cli.flag-command-error-ux (owns only `os doctor --help`; this item is the command's first functional coverage)", + "sibling item cli.doctor-deprecation-scan (the --scan-deprecations flag's own item — cross-referenced, not duplicated)" + ], + "history": [ + { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): `os doctor` as a whole had NO checklist item; it appeared only as a --help variant on cli.flag-command-error-ux while carrying seven unit-pinned seams (env provenance #5387, posture honesty #5382, ledger independence #5429, withheld ✓ #5412/#5413/#5644). Authored from the run body at doctor.ts:1720-2172 with the withhold-the-✓ discipline the command itself established turned back on its own monorepo-anchored Test-coverage/Deprecations ✓s, which bless a tree they never examined in every user app — encoded as an expected-fail probe rather than silently accepted", "ref": "claude/new-session-0pv25p" } + ] + }, + { + "id": "cli.doctor-deprecation-scan", + "title": "os doctor --scan-deprecations: every retired pattern is found with file:line attribution, decoys are skipped, the run warns without gating — and the remediation hint prescribes a command that does not exist (expected-fail)", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "cli", + "personas": ["operator (local shell)", "upgrading author (pre-major sweep)"], + "fixtures": { + "app": "scaffold", + "requires": [ + "a scratch blank scaffold (`npx -y create-objectstack@latest qa-depscan -t blank` — the template ships src/objects/note.object.ts, the same provisioning cli.scaffold-first-run drives) whose src/ the run seeds with one hit per pattern", + "decoy files staged per run: a src/*.test.ts twin, a src/node_modules/dep/index.ts, and a src/*.json metadata file carrying a retired key — all three must stay unreported" + ], + "knownGaps": [ + "no test anywhere in the repo references --scan-deprecations — this item is the surface's first coverage of any kind, so there is no automated entry and no pin to lean on" + ] + }, + "steps": [ + "scaffold and seed src/legacy.ts with one line per DEPRECATED_PATTERNS entry — count the table yourself at doctor.ts:1186-1234 (8 entries at authoring time): EnhancedObjectKernel, max_length, default_value, min_length, the retired lookup-scoping pair (one entry, BOTH spellings: reference_filters AND referenceFilters — seed both lines), unique_name, `import ... from '@objectstack/core/enhanced'`, `import ... from '@objectstack/spec/dist/x'`", + "stage the decoys: src/legacy.test.ts with the same content, src/node_modules/dep/index.ts with the same content, and src/meta.json carrying \"referenceFilters\"", + "run `os doctor --scan-deprecations; echo $?` and capture the per-hit rows and the closing hint line verbatim; re-run with `--scan-deprecations --verbose` and capture the replacement prescriptions", + "clean pass: delete the seeds (keep the decoys) and re-run — expect '✓ Deprecation scan No deprecated patterns found' and exit 0", + "absent-src probe: run `os doctor --scan-deprecations` from a scratch dir with NO src/ at all and capture that the SAME success line prints", + "record `os help codemod; echo $?` (or `os codemod --help`) from the same shell — the command the hint prescribes" + ], + "acceptance": [ + { + "clause": "every seeded pattern is reported with file:line attribution matching the seeded lines — 8 registry entries, with the retired-lookup entry attributing BOTH its spellings (reference_filters and referenceFilters land on one regex, doctor.ts:1215)", + "oracle": "log", + "verify": "each warning row reads ':' (doctor.ts:2143) and the file:line pairs match where the seeds were written; no seeded line is missing and no unseeded line is reported", + "evidence": "the seeded file with line numbers + the transcript rows" + }, + { + "clause": "--verbose prints each hit's replacement prescription ('Use maxLength (camelCase)', 'Use lookupFilters … removed in #2377', …); the bare run withholds them", + "oracle": "log", + "verify": "the dim '→ ' lines appear under --verbose only (doctor.ts:2144-2146)", + "evidence": "the two transcripts diffed" + }, + { + "clause": "the scan WARNS and never gates: a run with hits prints the warning rows and the count line yet exits 0 (hits feed hasWarnings only, never hasErrors), and the clean run prints the success line and exits 0 — both sides captured", + "oracle": "log", + "verify": "echo $? is 0 for both the seeded and the clean run; the seeded run's summary is the '⚠️ Environment is functional' branch (doctor.ts:2140-2152 sets hasWarnings; exit 1 is reserved for error rows at :2158)", + "evidence": "both exit codes + summary lines" + }, + { + "clause": "decoys stay silent, each for its own documented reason: *.test.ts filtered (doctor.ts:1240), node_modules pruned by the walk (:1130), and the .json file never visited because the walk is .ts-only (:1240) — the .json silence is recorded as a DOCUMENTED BOUNDARY of the scan (retired keys in JSON metadata are out of its reach), never as evidence the metadata is clean", + "oracle": "log", + "verify": "no transcript row names legacy.test.ts, node_modules, or meta.json; the run record states the .ts-only boundary explicitly", + "evidence": "the transcript + the staged decoy listing" + }, + { + "clause": "absent-src honesty: with no src/ directory the scanner returns [] (doctor.ts:1238) and doctor prints the SAME success line a genuinely clean tree gets — so the success line alone is NOT evidence of cleanliness. The run must pair the line with proof src/ existed and was walked; a run record citing the line without that proof is the absence-inference false positive this clause exists to block", + "oracle": "log", + "verify": "the no-src run prints '✓ Deprecation scan No deprecated patterns found'; the run record annotates it as scanned-nothing, with `ls src` refusal captured alongside", + "evidence": "the no-src transcript + the ls refusal" + }, + { + "clause": "EXPECTED-FAIL (shippable defect — record actual behavior): the remediation hint printed after hits (doctor.ts:2149) prescribes `objectstack codemod v2-to-v3`, but no codemod command is registered anywhere in packages/cli/src/commands/ — the prescription is a dead end, and content/docs/protocol/backward-compatibility.mdx:134 admits the command is 'not yet available'. The real metadata codemod is `os migrate meta` (cli.migrate-meta-codemod). The clause: the hint must name a command os actually registers; today it does not", + "oracle": "log", + "verify": "the hint line is captured verbatim from the seeded run, and `os codemod --help` errors 'command … not found' (nonzero) from the same binary — the pair is the failure evidence; the clause flips to pass when the hint names a registered command", + "evidence": "the hint line + the command-not-found capture" + } + ], + "negative": [ + "a hit reported from a *.test.ts or node_modules path is a filter regression", + "a run with hits exiting nonzero is a gate this surface never had — the scan is advisory by design", + "a run record that ticks the clean clause on the success line alone, without proving src/ existed, is the absence-inference false positive", + "the retired-lookup entry reporting only one of its two spellings while both were seeded" + ], + "traps": ["absence-inference", "stale-dist"], + "source": [ + "packages/cli/src/commands/doctor.ts (flag :1717; scan block :2136-2153 with the scanDir = /src at :2138 and the hint at :2149; DEPRECATED_PATTERNS :1186-1234; scanDeprecatedPatterns :1236-1265 — absent-dir [] at :1238, .ts-only walk + .test.ts filter :1240; walkDir node_modules prune :1130)", + "content/docs/protocol/backward-compatibility.mdx:126-134 (the documented workflow, including the :134 admission that the codemod command is not yet available)", + "packages/cli/README.md §os doctor (documents -v and --scan-deprecations)", + "packages/create-objectstack/src/templates/blank/ (the scaffold whose src/ the seeds land in)", + "sibling items cli.doctor-health-report (the command's own report contract) and cli.migrate-meta-codemod (the codemod that actually exists)" + ], + "history": [ + { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能), found independently by two hunters and re-verified against source: --scan-deprecations had zero coverage of any kind (no test in the repo references it). The 8-entry pattern table was recounted from doctor.ts:1186-1234; brief line-drift corrected during authoring (absent-dir return is :1238 not :1273, the scanner body ends at :1265 not :1276). The dead `objectstack codemod v2-to-v3` prescription at :2149 is encoded as an expected-fail clause with the docs' own 'not yet available' admission as corroborating source", "ref": "claude/new-session-0pv25p" } + ] + }, + { + "id": "cli.migrate-duplicates-inventory", + "title": "os migrate duplicates: a read-only JSON inventory of identifiers minted across partitions — within-partition repeats excluded, nothing written, the live two-counter condition reported, and runnable BEFORE the #8686 repair destroys the evidence", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "cli", + "personas": ["operator (local shell, pre-repair audit)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a scratch app + persistent sqlite DB whose base schema was synced by ONE prior `os dev` boot (the same recipe cli.migrate-plan-apply-json uses), e.g. -d file:/tmp//dup.db — the app config must declare an org-scoped object with an autonumber (or unique) field", + "direct SQL seeding after that boot (sqlite3 CLI or a node better-sqlite3 one-liner): one identifier value held by a row with organization_id NULL AND a row with organization_id '' (the cross-partition duplicate); the same value twice WITHIN '' on a second identifier (the excluded repeat); and two _objectstack_sequences rows for the same object/field — one __global__ tenant, one org-scoped (the live condition)" + ], + "knownGaps": [ + "ORDERING IS LOAD-BEARING: do NOT boot the dev server again between seeding and scanning — a #8686-repairing boot backfills organization_id = NULL, which is exactly the evidence this report inventories (the command's own header calls the evidence perishable). The duplicates command itself boots read-only (deferSchemaDdl + readOnlyProbe) and is safe to run any number of times", + "the memory and mongodb drivers expose no raw-SQL seam, so the negative no_sql_seam probe needs a second scratch config on the memory driver — stage it per run" + ] + }, + "steps": [ + "boot the scratch app once with `os dev -d file:/tmp//dup.db` so the base schema exists; stop it", + "seed via direct SQL per the fixture recipe: the cross-partition duplicate, the within-partition repeat, an organizations row for '', and the paired sequence counters", + "md5sum the DB file; run `os migrate duplicates > report.json; echo $?`; md5sum again and byte-compare", + "jq the report: .report/.reportVersion/.generatedAt/.database/.globalPartition/.filter/.counters/.scanned/.skipped/.duplicates/.liveConditions/.summary", + "run `os migrate duplicates --object ` and `--object ` — capture .filter in both payloads", + "run `os migrate duplicates --database-url file:/tmp//dup.db` and confirm it reaches the same DB (the flag also honors OS_DATABASE_URL)", + "negative: from the memory-driver scratch config run `os migrate duplicates; echo $?` and capture the refusal payload", + "stderr/stdout split: confirm report.json parses as ONE JSON document — the boot's own log lines must have gone to stderr (#6217)" + ], + "acceptance": [ + { + "clause": "the report is the declared machine-readable contract on stdout: report 'duplicate-identifiers', reportVersion 1, generatedAt, database, globalPartition, filter, counters {table, status read|absent}, scanned[], skipped[], duplicates[], liveConditions[], summary — and each duplicate carries object/field/value/holderCount/partitions plus per-holder id/organization/partition/createdAt (createdAt null when the object has no such column, never a failed probe)", + "oracle": "log", + "verify": "jq walks every declared key of the seeded run's payload; shape pinned by duplicates.contract.test.ts — cite its pass for the full-shape guarantee, drive the CLI for the instance", + "evidence": "report.json + the jq walk" + }, + { + "clause": "the ruled definition of duplicate holds (#8928 point 2): the value held in two COALESCE(organization_id,'__global__') partitions IS reported; the value repeated WITHIN one partition is NOT — the partitioned unique index owns that defect", + "oracle": "log", + "verify": "the cross-partition seed appears in .duplicates with both partitions listed; the within-partition seed appears nowhere in .duplicates (the probe demands COUNT(*) > 1 AND COUNT(DISTINCT partition) > 1, duplicates.ts:263-266)", + "evidence": "the seeded values vs the report's duplicates array" + }, + { + "clause": "read-only in fact, not just in intent: the DB file is byte-identical before and after a full run — no DDL, no seed, no row written (deferSchemaDdl + readOnlyProbe boot, duplicates.ts:670-679)", + "oracle": "log", + "verify": "the two md5sums match; duplicates.pre-repair.test.ts pins the same invariant down to _objectstack_sequences", + "evidence": "the md5 pair" + }, + { + "clause": "the live condition (#8928 point 4) fires exactly when a __global__ counter sits beside an org-scoped counter for the same object/field — reported as a prediction with globalLastValue and the per-org counters, and counters.status says where it was read from ('absent' still yields a complete duplicates inventory)", + "oracle": "log", + "verify": ".liveConditions names the seeded object/field with both counters' last values; dropping the org counter row and re-running empties it while .duplicates is unchanged", + "evidence": "the two runs' liveConditions diffed" + }, + { + "clause": "--object narrows the scan AND records the narrowing: .filter is {object} on a filtered run and null on a full run, so an archived narrowed report can never be mistaken for a full scan", + "oracle": "log", + "verify": "the three payloads' .filter fields (null / the object / the no-findings object) match the invocations", + "evidence": "the .filter captures" + }, + { + "clause": "--database-url (and OS_DATABASE_URL) points the inspection at a named DB directly, and a target the command could not probe lands in .skipped with its reason — never silently omitted from a report that then reads as clean", + "oracle": "log", + "verify": "the --database-url run returns the same duplicates as the project-resolved run; any unprobeable (object, field) appears in .skipped with reason text", + "evidence": "the paired payloads" + }, + { + "clause": "a driver with no raw-SQL seam refuses LOUDLY: {error: 'no_sql_seam', …} with exit 1 — an empty clean report from a driver the probe cannot run against would be indistinguishable from 'never looked'", + "oracle": "log", + "verify": "the memory-driver run emits the no_sql_seam payload and echo $? is 1 (duplicates.ts:697-712); a boot failure likewise answers {error: 'boot_failed'} exit 1, never a zero-duplicate success", + "evidence": "the refusal payload + exit code" + } + ], + "negative": [ + "any write to the inspected DB — a created sqlite file, a synced column, a mutated counter — is the FAIL the ruling's read-only boot exists for", + "a within-partition repeat surfacing in .duplicates misdiagnoses a unique-index defect as tenancy damage", + "there is NO --json flag: stdout is always the report (the payload IS the deliverable, #8928 point 3) — probing `--json` and reading the oclif Nonexistent-flag exit 2 as this command's contract is a runner error, not a finding", + "an empty .duplicates from a run whose .skipped is non-empty must not be summarized as 'no duplicates' without naming what was skipped" + ], + "traps": ["stale-dist", "absence-inference"], + "source": [ + "packages/cli/src/commands/migrate/duplicates.ts (the :17-80 contract header encoding the 2026-08-16 maintainer ruling's five points; report interfaces :82-165; the cross-partition HAVING at :263-266; flags :648-656; read-only boot :670-679; no_sql_seam refusal :697-712)", + "packages/cli/src/commands/migrate/duplicates.contract.test.ts (the full JSON shape against a real sqlite), duplicates.pre-repair.test.ts (byte-identical DB + the #8686 repair measured destroying the evidence), duplicates.integration.test.ts, duplicates.probe-sql.test.ts — seam pins; none drives the oclif command end-to-end, hence no automated entry", + "#8928 (the card and ruling), #8686 / #8844 (the closed producers whose damage this inventories)", + "sibling item cli.migrate-plan-apply-json (lists duplicates as a variant; the scratch-DB boot recipe is shared)" + ], + "history": [ + { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): `os migrate duplicates` landed 2026-08-16 (#8928) after the sibling migrate item's enumeration was authored, so the subcommand had no functional coverage. Authored from the :17-80 contract header's five ruling points, with the perishability ordering (seed → scan → only then any repair-bearing boot) carried as a load-bearing knownGap and the no-JSON-flag posture spelled out so the #4873 sweep does not misread an oclif 2", "ref": "claude/new-session-0pv25p" } + ] + }, + { + "id": "cli.datasource-introspect-codegen", + "title": "os datasource list-tables/introspect/validate: the federation door answers, the generated draft is cwd-jailed and compiles, and schema drift maps to ✗ diffs with exit 1", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "cli", + "personas": ["operator (local shell)", "integration author (adopting a remote table)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a booted showcase (`pnpm dev -- --fresh -p `) — it ships the external datasource fixture: examples/app-showcase/src/system/datasources/showcase-external.datasource.ts (name 'showcase_external', sqlite file .objectstack/data/showcase_external.db) seeded by external-fixture.ts", + "an admin session token minted via POST /api/v1/auth/sign-in/email (the external-datasource routes sit behind the auth guard — external-datasource-routes-auth-guard.test.ts); pass it as --token / OS_TOKEN, with --url / OS_CLOUD_URL pointing at the boot port", + "a scratch objects/ dir in the invocation cwd for the --out draft, and a scratch config to wire the draft into for the compile clause" + ], + "knownGaps": [ + "no test anywhere references the three CLI commands themselves (the REST side is tested; the oclif wrappers are not) — this item is their first functional coverage, so there is no automated entry", + "the #5594 carve-out applies to the fixture DB: .objectstack/data/showcase_external.db is a cwd-relative app-declared path that survives --fresh by documented design — do not file its persistence as a bug" + ] + }, + "steps": [ + "boot showcase on a scratch port and mint the admin token (the cli.qa-suite-execution recipe)", + "`os datasource list-tables showcase_external --url http://localhost: --token ` — capture the table listing; repeat with `--schema ` and with a schema that matches nothing ('No remote tables found.')", + "`os datasource introspect showcase_external --table --url … --token ` — draft printed to stdout; then re-run with `--out objects/qa_external.object.ts` and capture 'Wrote objects/qa_external.object.ts' plus any 'REVIEW: column …' warnings", + "escape probes: re-run with `--out ../qa-escape.object.ts` and `--out /tmp/qa-escape.object.ts` — capture both refusals and `echo $?`, and confirm neither file exists afterwards", + "wire the generated object into the scratch config and run `os build; echo $?`", + "`os datasource validate showcase_external --url … --token ; echo $?` on the untouched fixture (expect ✓ rows, exit 0); then rename a column in the fixture DB (sqlite3 ALTER TABLE) or edit the adopted object's column mapping, re-run, and capture the ✗ diff rows and exit 1", + "negative: run all three commands against a datasource name that does not exist and capture each error + exit code" + ], + "acceptance": [ + { + "clause": "list-tables prints the remote tables with schema qualification and column counts (plus row estimates when the driver reports them) through GET /api/v1/datasources/:name/external/tables, and --schema narrows via the ?schema= query — this item pins the CLI's /external/tables FEDERATION door specifically; the admin twin GET /:name/remote-tables is a different mount owned by the integration-system area", + "oracle": "log", + "verify": "the listing matches the fixture DB's actual tables (cross-check with sqlite3 .tables on showcase_external.db); the schema filter narrows and the no-match run prints 'No remote tables found.' (list-tables.ts:38-57; route registered in packages/rest/src/external-datasource-routes.ts, ledgered at rest-route-ledger.ts:357)", + "evidence": "the listings + the sqlite3 cross-check" + }, + { + "clause": "introspect generates an Object draft from the remote table (POST …/external/tables/:remote/draft) — stdout by default, --out writes the file and echoes the path, and draft.review notes surface as REVIEW: warnings rather than being dropped", + "oracle": "log", + "verify": "the draft source names the remote table's columns; the --out run writes exactly objects/qa_external.object.ts; review notes (if the fixture produces any) print as 'REVIEW: column … — …' (introspect.ts:42-83)", + "evidence": "the draft + the written file + any REVIEW lines" + }, + { + "clause": "the --out jail holds BOTH ways: an absolute path and a cwd-escaping relative path are each refused with '--out must be a relative path within the current directory', exit nonzero, and NO file is written — the body is server-generated TypeScript, so the jail is a security boundary against a compromised server, not a convenience check", + "oracle": "log", + "verify": "both escape probes print the worded refusal (introspect.ts:65-74 — resolve() against cwd, isAbsolute + prefix check) with nonzero exit, and ls confirms neither target exists", + "evidence": "both refusals + exit codes + the absence listing" + }, + { + "clause": "the generated draft COMPILES: wired into a config, os build exits 0 — the codegen's output is a working input to the authoring pipeline, not a sketch", + "oracle": "build", + "verify": "os build on the scratch config carrying the draft exits 0 and the artifact contains the object; any refusal names what the draft got wrong, and that gap is the finding", + "evidence": "build exit code + the artifact excerpt" + }, + { + "clause": "validate is the drift detector with an honest exit: matching objects print '✓ matches'; a mismatch prints per-diff rows (kind, column, expected vs actual, ✗ for error / ⚠ for warning) and the run exits 1 exactly when an error-severity diff exists", + "oracle": "log", + "verify": "the untouched run is all-✓ exit 0; the staged-drift run prints the ✗ row naming the renamed column and echo $? is 1 (validate.ts:59-74 — hasError gates this.error(…, {exit: 1}))", + "evidence": "both transcripts + exit codes" + }, + { + "clause": "the server's error arm is honored on all three commands: a body.error (unknown datasource, driver failure) becomes this.error — a printed error WITH a nonzero exit, never a 0", + "oracle": "log", + "verify": "the unknown-name probes each exit nonzero with the server's own error text (list-tables.ts:46, introspect.ts:57, validate.ts:51)", + "evidence": "the three error captures + exit codes" + } + ], + "negative": [ + "an --out escape that lands a server-authored file outside the project tree is the security FAIL the jail exists for — the refusal must be proven, not assumed from the code", + "validate exiting 0 while an error-severity diff printed breaks every CI wrapper watching for schema drift", + "a draft that does not compile through os build makes the adoption workflow a dead end — the whole point of codegen", + "route-spelling trap: the CLI addresses /external/tables (federation, packages/rest); probing the admin /remote-tables spelling and reading its 404 as 'the CLI's route is gone' conflates two mounts — the API-door coverage of the admin twin belongs to the integration-system area, not here" + ], + "traps": ["dispatcher-vs-hono-route", "seed-data-thin", "wrong-persona"], + "source": [ + "packages/cli/src/commands/datasource/list-tables.ts (:12-15 route doc, :38-57 listing), introspect.ts (:14-18 route doc, :31-36 flags, :65-74 the cwd jail, :81-83 REVIEW passthrough), validate.ts (:12-16 route doc, :59-74 diff rendering + exit)", + "packages/rest/src/external-datasource-routes.ts + rest-route-ledger.ts:357-361 (the served federation routes incl. POST …/external/validate) and external-datasource-routes-auth-guard.test.ts (the auth wall the token satisfies)", + "examples/app-showcase/src/system/datasources/showcase-external.datasource.ts + external-fixture.ts (the fixture datasource 'showcase_external')", + "ADR-0015 (external datasource federation)", + "sibling item cli.flag-command-error-ux (owned only the topic's --help until now)" + ], + "history": [ + { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): the datasource topic's three subcommands had only a --help variant, no functional coverage. Re-verified against source during authoring: the hunter brief asked whether an --out escape refusal exists — it DOES (introspect.ts:65-74, absolute + traversal both refused before any write), so the jail is asserted positively with both escape probes rather than worded observe-and-flag. The /external/tables-vs-/remote-tables mount split is recorded as a runner trap with the admin twin explicitly routed to the integration-system area", "ref": "claude/new-session-0pv25p" } + ] + }, + { + "id": "cli.hook-body-extraction-gates", + "title": "Hook/action body extraction: --strict-body refuses every forbidden pattern with its own worded reason, capability tokens are inferred (and crypto.hash is NOT, #4391), free identifiers fall back to bundling — and the DEFAULT build's silent fallback contradicts the extractor's own contract (expected-fail)", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "cli", + "personas": ["build-time author (no session)"], + "fixtures": { + "app": "scaffold", + "requires": [ + "a scratch blank scaffold whose config authors the probe hooks — one per FORBIDDEN_PATTERNS entry, one per CAPABILITY_PATTERNS shape, the ctx.crypto.hash regression probe, the @capabilities override, the const-api alias, an implicit-return arrow, and one handler calling a module-scope helper", + "jq over dist/objectstack.json — body presence and body.capabilities per hook are the artifact-side oracle" + ], + "knownGaps": [ + "the RUNTIME half of every token (the sandbox rejecting an undeclared call, log routing) is records-forms territory (its L2 sandbox item cites body-runner.ts:321) — this item owns the BUILD-side gate and inference only; do not re-prove the sandbox here", + "bodyExtractionWarnings are invisible on a default build (see the expected-fail clause), so the default-path evidence must come from the artifact (body absent, bundle emitted), not from output that will not appear" + ] + }, + "steps": [ + "author the forbidden seven, one hook each: dynamic import(, require(, fetch(, process., globalThis., eval(, new Function( — then run `os build --strict-body; echo $?` and capture every per-callable diagnostic verbatim", + "the default-path pair on the SAME config: `os build; echo $?` — capture the exit, the 'Bundling N handler(s)…' line, and jq the artifact for each hook's body (expect ABSENT) and handler ref (expect present)", + "the inference matrix, one hook each on a clean config: ctx.api.object('x').find(…) · ctx.api.object('x').update(…) · const api = ctx.api; api.object('x').find(…) · ctx.crypto.randomUUID() · ctx.log.info(…) · a body whose ONLY crypto call is ctx.crypto.hash(…) · a body with '// @capabilities api.read api.write' as its first line and no matching calls · an implicit-return arrow — `os build` then jq each hook's body.capabilities and isExpression handling", + "free-identifier probe: a handler calling a module-scope helper function — `os build; echo $?` (expect green, handler bundled, no body) then `os build --strict-body; echo $?` (expect exit 1 naming the identifier)", + "all-body-only run: remove every non-extractable handler and confirm the 'Skipping legacy runtime bundle (all N callables are body-only)' line and that no objectstack-runtime.*.mjs remains in dist/", + "re-run the strict-body failure with --json and capture the { success: false, error: 'strict-body: missing body', issues } payload" + ], + "acceptance": [ + { + "clause": "--strict-body refuses EACH forbidden pattern with that pattern's own worded reason — fetch and import prescribe 'declare a Connector recipe instead', require/process/globalThis/eval/new Function each name themselves — never a bare parse error, and the run exits 1 (the --json arm emits the issues payload then exits 1)", + "oracle": "build", + "verify": "the seven diagnostics each quote the matching FORBIDDEN_PATTERNS reason (extract-hook-body.ts:33-41) under the '--strict-body: N callable(s) lack a metadata body' error (compile.ts:126-149); echo $? is 1 on both the human and --json paths", + "evidence": "the seven verbatim diagnostics + exit codes + the --json payload" + }, + { + "clause": "read/write inference from the artifact: .object(x).find → api.read and .object(x).update → api.write land in body.capabilities — and the const-api ALIAS is still caught, because the regex deliberately matches any chain ending in .object(…). (over-inclusive by design; a false-positive token is rejected at runtime by the sandbox, not silently honored at build)", + "oracle": "build", + "verify": "jq shows ['api.read'] on the find hook, ['api.write'] on the update hook, and ['api.read'] on the alias hook (CAPABILITY_PATTERNS at extract-hook-body.ts:49-50 with the :44-48 rationale)", + "evidence": "the jq captures per hook" + }, + { + "clause": "ctx.crypto.randomUUID → crypto.uuid and ctx.log. → log are inferred; and the #4391 regression clause: ctx.crypto.hash infers NOTHING — the crypto.hash token was removed because the sandbox never installed the function, and inferring a capability from a call that always threw is what let os build bless a dead body", + "oracle": "build", + "verify": "jq shows ['crypto.uuid'] and ['log'] on their hooks, and the hash-only hook's body.capabilities is [] — any crypto token there is the pinned regression (extract-hook-body.ts:51-56, the :52-55 removal note)", + "evidence": "the three jq captures" + }, + { + "clause": "the '// @capabilities …' first-line override adds exactly the named tokens (from the closed set api.read/api.write/crypto.uuid/log) even when no call pattern matches, merged with any inference — the author's declaration wins additively", + "oracle": "build", + "verify": "the override hook with no matching calls carries ['api.read','api.write'] in the artifact (extract-hook-body.ts:118-131; documented at content/docs/automation/hook-bodies.mdx:320-327)", + "evidence": "the jq capture" + }, + { + "clause": "#1876 self-containment: a handler referencing a module-scope identifier throws out of extraction naming the identifier(s), the caller catches and keeps the handler BUNDLED (no body, handler ref into the .mjs) so the default build stays green with no behavior change — while --strict-body surfaces the same message as a hard failure", + "oracle": "build", + "verify": "default run: exit 0, jq shows no body on that hook, the runtime bundle is emitted; strict run: exit 1 with 'references identifier(s) not in scope at runtime: ' (extract-hook-body.ts:94-109; the catch-and-bundle at lower-callables.ts:63-78)", + "evidence": "both runs' exits + the jq capture + the strict diagnostic" + }, + { + "clause": "an all-body-only config skips the legacy bundle: the skip line prints, no objectstack-runtime.*.mjs lands in dist/, and previously emitted bundles are cleaned — the artifact is a single self-describing JSON", + "oracle": "build", + "verify": "the 'Skipping legacy runtime bundle' line (compile.ts:390-393) + an empty dist/ glob for the .mjs after a run that previously emitted one", + "evidence": "the line + the dist listing" + }, + { + "clause": "EXPECTED-FAIL (contract contradiction — record actual behavior): on a DEFAULT `os build`, a forbidden-pattern hook ships SILENTLY via the .mjs bundle — the extraction failure is caught (lower-callables.ts:63-78), the warning is recorded in bodyExtractionWarnings but printed NOWHERE on the default path and excluded from the --json success payload (whose warnings key carries rule advisories only, compile.ts:437) — while the extractor's own contract header promises the build FAILS with 'no silent fallback to the L3 .mjs path because that path is being closed' (extract-hook-body.ts:14-18). hook-bodies.mdx:256 documents the warn-and-bundle default, so the DOCS and the CODE agree with each other and both contradict the extractor's stated contract. The clause: a forbidden pattern on the default path must at least SURFACE its warning; today the only observable trace is the bundle's existence", + "oracle": "build", + "verify": "the default run over the forbidden seven exits 0 with zero extraction-related output — grep the transcript and the --json payload for any of the seven reasons (expect none) while jq confirms all seven hooks are body-less and the bundle was emitted; the clause FAILS on that silence and flips when the warnings surface (or the header's fail-the-build contract is actually enforced)", + "evidence": "the silent transcript + --json payload + the jq body-absence sweep" + } + ], + "negative": [ + "a forbidden body reaching the artifact AS body.source is the sandbox-bypass FAIL the whole allow-list exists for — worse than any warning question", + "a crypto token inferred from ctx.crypto.hash is the #4391 regression: a build blessing a body that can only throw at runtime", + "--strict-body exiting 0 while any callable lacks a body, or a bare 'could not parse' where a pattern's worded reason belongs", + "an alias (const api = ctx.api) escaping api.read/api.write inference would ship a body the sandbox then rejects at first call — the over-inclusive regex is deliberate, its narrowing is a regression", + "this item does NOT own the runtime sandbox: proving a token's enforcement belongs to records-forms' L2 sandbox coverage, and the build's generic exit contract to cli.build-own-contract" + ], + "traps": ["stale-dist", "absence-inference"], + "source": [ + "packages/cli/src/utils/extract-hook-body.ts (contract header :3-29 — whose fail-the-build sentence the expected-fail clause tests; FORBIDDEN_PATTERNS :33-41; CAPABILITY_PATTERNS :43-57 with the #4391 removal note :52-55; #1876 free-identifier throw :94-109; @capabilities override :118-131)", + "packages/cli/src/utils/lower-callables.ts (:33-36 the warnings field, :63-78 tryExtractBody's catch-all fallback-to-bundle — the seam the default path's silence flows from)", + "packages/cli/src/commands/compile.ts (:126-149 the --strict-body gate, :366-393 the needsBundle decision + skip line, :437 the --json warnings key that excludes extraction warnings)", + "content/docs/automation/hook-bodies.mdx (:254-256 the documented default + --strict-body posture; :311-327 the inference table + override)", + "packages/cli/src/utils/lower-callables.test.ts (the existing unit seam pin — cited, not a substitute for driving os build)", + "sibling items records-forms (runtime sandbox side, body-runner.ts) and cli.build-own-contract (the build's own exit/output contract)" + ], + "history": [ + { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能): a security grant derived from a regex had no checklist item — only the runtime side of one token (records-forms) and the build's generic exit contract were covered. RE-VERIFIED against source with one material correction to the hunter brief: a forbidden pattern does NOT fail a default `os build` — lower-callables.ts:63-78 catches every extraction error and falls back to the bundle, the warnings print nowhere on that path, and only --strict-body (compile.ts:126-149) produces the worded refusals; hook-bodies.mdx:256 documents exactly that, while the extractor's own header still promises fail-with-no-fallback. The worded-refusal clauses are therefore pinned to --strict-body, and the default path's silence is encoded as the expected-fail contradiction clause", "ref": "claude/new-session-0pv25p" } + ] + }, + { + "id": "cli.lint-severity-exit-contract", + "title": "os lint: the three-severity vocabulary is closed, exit is 1 exactly on errors, --json is the declared lint shape with duration inside the payload, the #4409 registry keeps lint and build in agreement, and the i18n fold hides platform noise honestly", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "cli", + "personas": ["operator (local shell)", "metadata author (pre-build hygiene)"], + "fixtures": { + "app": "scaffold", + "requires": [ + "a scratch blank scaffold whose config the probes stage: one error-severity finding (a camelCase object name → naming/snake-case, or a missing label → required/label), one warning (a lowercase label → convention/label-case), and one #4409 gating-rule breakage shared with cli.build-own-contract's step (the Approval approver expression that does not parse)", + "a throwing config in a second scratch dir for the load-failure probes" + ], + "knownGaps": [ + "no staged fixture reliably produces a 'suggestion' finding (they arrive via registry 'info' findings and data-model advisories), so the suggestion tier is asserted through the vocabulary/shape clauses rather than a staged instance — record which severities the run actually produced", + "--score and --eval are separate surfaces (the metadata-quality rubric and the generation eval) deliberately out of this item's scope — they need their own item if coverage is wanted", + "the stock showcase ships translations, so the platform-fold probe runs on the scaffold where the metadataForm baseline is the only i18n noise" + ] + }, + "steps": [ + "stage the error + warning findings and run `os lint; echo $?` — capture the grouped Errors/Warnings/Suggestions sections with their ✗/⚠/ℹ icons and per-issue rule/path lines", + "fix the error, keep the warning: `os lint; echo $?` (expect exit 0 — warnings never gate)", + "md5sum the config; run `os lint --fix; echo $?` and capture the '→ fix:' suggestions and the 'Dry-run mode: no files were modified.' line; md5sum again", + "`os lint --json; echo $?` on both the error-bearing and warnings-only configs — capture the full payloads and exits", + "the registry-parity pair: on the broken-approver config run BOTH `os lint; echo $?` and `os build; echo $?` and compare which findings each reports", + "the i18n fold: on the scaffold run bare `os lint` (capture the dim 'platform built-ins: N i18n issue(s) hidden — rerun with --include-platform' line when it appears), then `os lint --include-platform` (the metadataForm findings surface), then `os lint --skip-i18n` (the fold is absent entirely)", + "load failure: in the throwing-config dir run `os lint; echo $?` and `os lint --json; echo $?` — capture the message/payload and exits" + ], + "acceptance": [ + { + "clause": "the severity vocabulary is closed at error | warning | suggestion: every reported issue carries one of the three, the human report groups by them with ✗/⚠/ℹ, and each issue names its rule and path", + "oracle": "log", + "verify": "every issues[].severity in the --json payloads is one of the three (the Severity type, lint.ts:30); the human sections match (lint.ts:577-615)", + "evidence": "the payloads + the grouped transcript" + }, + { + "clause": "exit contract: 1 exactly when errors > 0 — warnings and suggestions NEVER flip it, on either path (--json's exit slot is errors.length > 0 ? 1 : 0 through CliExitCode; the human path exits via process.exit(1) only under errors) — and duration reports INSIDE the --json payload, never in the exit slot (the #4873 class; format.ts's own doc names os lint as the command that does this correctly)", + "oracle": "log", + "verify": "the error-bearing runs exit 1, the warnings-only runs exit 0, on both paths (lint.ts:543-553 and :634); the payload carries a numeric duration key and the exit is exactly 0 or 1, stable across a repeat run", + "evidence": "the four exit codes + the payload's duration key + one repeat" + }, + { + "clause": "--json emits the DECLARED lint shape: { passed, total, errors, warnings, suggestions, hiddenPlatform?, score?, issues: [{severity, rule, message, path, fix?}], duration } with the counters reconciling against issues[] — note this is lint's OWN shape, not os validate's (the #3782 build/validate parity class does not extend here; the parity lint owes is the registry clause below)", + "oracle": "log", + "verify": "jq walks the declared keys; recomputed per-severity counts from issues[] equal the counters; passed === (errors === 0) (lint.ts:539-554)", + "evidence": "the payload + the jq reconciliation" + }, + { + "clause": "#4409 registry parity: os lint runs the shared authoring-rule registry as its single call site, so it cannot disagree with os build about registry rules in either direction (the pre-registry lint returned clean for stacks build rejects AND rejected stacks build ships) — the staged broken-approver finding appears in BOTH commands' output with its rule id, and registry 'info' findings map to lint's 'suggestion' tier", + "oracle": "log", + "verify": "the broken-approver rule id appears in the lint output AND the build refusal on the same config (runAuthoringRules('lint', …) at lint.ts:428-436 with the :407-427 rationale; build's side is cli.build-own-contract clause 3); the info→suggestion mapping is :430", + "evidence": "the paired lint/build transcripts" + }, + { + "clause": "the i18n fold separates platform from app findings honestly: metadataForm (platform-registry) coverage issues are hidden by default BUT counted — the dim disclosure line names how many were hidden and how to see them — --include-platform surfaces them as i18n/missing-metadataForm issues at translations.. paths, and --skip-i18n removes the fold entirely; hiding must never be silent (the 15.1 third-party eval drowned 848 user findings in platform noise, which is why the fold exists)", + "oracle": "log", + "verify": "the bare run's hiddenPlatform count (payload) and disclosure line (human) match the --include-platform run's surfaced metadataForm issue count; the --skip-i18n payload carries no i18n/* rules (foldCoverageIssues at lint.ts:47-66; disclosure :559-567)", + "evidence": "the three runs' payloads/transcripts diffed" + }, + { + "clause": "--fix is a dry run in fact: the '→ fix:' suggestions print, the 'no files were modified' line prints, and the config file is byte-identical after", + "oracle": "log", + "verify": "the md5 pair matches (flag help says dry-run, lint.ts:452; the mode line :627-630)", + "evidence": "the md5 pair + the transcript" + }, + { + "clause": "a config that fails to load exits 1 on both paths, and the --json path still emits one parseable {error} document — never a stack trace masquerading as a payload, never exit 0", + "oracle": "log", + "verify": "both throwing-config runs exit 1; the --json output parses as a single JSON document carrying the error message (lint.ts:636-645)", + "evidence": "the captures + exit codes" + } + ], + "negative": [ + "a warning or suggestion flipping the exit to 1 turns the hygiene tool into a false gate; an error exiting 0 makes the gate decorative — both directions are the FAIL", + "platform i18n findings silently dropped WITHOUT the hiddenPlatform disclosure is dishonest hiding — the count is what keeps the fold auditable", + "--fix writing to any file is a dry-run contract violation", + "a lint that disagrees with os build about a #4409 registry rule in either direction is the pre-registry defect the shared table exists to prevent — re-verify with the paired runs before filing which side is wrong", + "the hunter brief expected validate-shaped --json parity here; source shows lint's own shape (lint.ts:543-553) — a runner asserting validate's keys against lint is a checklist error, not a product finding" + ], + "traps": ["stale-dist", "seed-data-thin"], + "source": [ + "packages/cli/src/commands/lint.ts (Severity :30; foldCoverageIssues :47-66; lintConfig's own rubric + the #4409 registry call :407-436 with info→suggestion :430; flags :450-478; --json emit :539-554; disclosure :559-567; human exit :634; load-failure catch :636-645; --fix dry-run :452/:627-630)", + "packages/cli/src/utils/collect-docs.ts (the ADR-0046 docs lint folded into the same issues stream)", + "packages/cli/src/utils/format.ts (CliExitCode :41 and the #4873 doc note naming os lint as the duration-inside-the-payload exemplar)", + "sibling item cli.build-own-contract (clause 3 is the build side of the registry-parity pair; its #3782 --json parity clause binds build↔validate, NOT lint)" + ], + "history": [ + { "revision": 1, "date": "2026-08-20", "change": "new — scoped scan-functionality sweep (扫描功能), authored VERIFY-FIRST as the brief required since the hunter had lower confidence here. Source confirmed the three-severity split and errors-only gating; two sketch claims were corrected against lint.ts before authoring: (1) the --json shape is lint's own {passed,total,errors,warnings,suggestions,issues,duration} (:543-553), not the os validate #3782 parity shape — the parity lint actually owes is the #4409 shared-registry agreement with os build (:407-436), encoded as its own clause; (2) the exit contract is 0/1 on both paths (process.exit(1) at :634, CliExitCode slot at :553), not build's human-path oclif-2 split. The i18n platform fold (:47-66) with its hiddenPlatform disclosure is covered as the third load-bearing surface", "ref": "claude/new-session-0pv25p" } + ] } ] } diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index 1750004e31..78e5344fd6 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -150,7 +150,7 @@ "title": "Every supported auth method signs in when enabled, is absent when disabled, and is advertised exactly as configured", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": ["anonymous visitor", "provisioned user per method", "admin (for env configuration)"], @@ -219,8 +219,8 @@ { "clause": "2FA is a server-driven gate: with 2FA enabled for the user, password sign-in alone does NOT yield a usable session until the challenge completes", "oracle": "api", - "verify": "the sign-in response demands the challenge; get-session before completing it does not return an authenticated user", - "evidence": "the challenge-flow trace" + "verify": "the sign-in response demands the challenge; get-session before completing it does not return an authenticated user. Cookie-lane completion is pinned END-TO-END by packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts (password sign-in answers twoFactorRedirect:true with a two-factor cookie; verify-totp through that cookie completes into a session) — cite the pin per rule 6 rather than hand-re-deriving the interrupt. The enrollment lifecycle AROUND this gate (reveal, verify-to-activate, backup codes, disable) is deep-tested by the identity-auth.two-factor-* items, not here", + "evidence": "the challenge-flow trace (or the cited pin output)" }, { "clause": "the OIDC authorization-code flow is pinned by automation — run the pin and cite its output rather than re-deriving the round trip by hand", @@ -255,7 +255,7 @@ "enterprise SSO / generic OIDC (oidcProviders[] via genericOAuth; login button gated on usable providers)", "social OAuth (socialProviders map, per-provider enabled)", "device authorization grant (RFC 8628 — CLI/TV login)", - "two-factor (server-driven challenge, ADR-0069)", + "two-factor (server-driven challenge, ADR-0069; cookie-lane completion pinned end-to-end by two-factor-lockout.dogfood.test.ts — arm with OS_AUTH_TWO_FACTOR=true BEFORE boot, the plugin list is resolved once at auth-manager construction; enrollment lifecycle owned by identity-auth.two-factor-*)", "magic link (server endpoints live; no login UI and no advertised flag since #7481 — blocked, objectui#4179)", "passkeys (plugin flag accepted but nothing wired; no advertised flag since #7481 — blocked, objectui#4179)" ], @@ -266,11 +266,13 @@ "packages/spec/src/api/auth-endpoints.zod.ts (AuthEndpointPaths; AuthFeaturesConfigSchema; device-flow response schemas)", "packages/plugins/plugin-auth/src/auth-route-ledger.ts (BETTER_AUTH_MOUNTED_SURFACE: the live change-email/delete-user + /.well-known/* rows; auth-plugin.ts mounts the two discovery docs at app root)", "packages/spec/src/kernel/public-auth-features.ts (flag semantics, gated inputs, the objectui#2513 known gap, and PUBLIC_AUTH_FEATURES_NOT_ADVERTISED — the reserved-but-unserved record for magicLink/passkeys)", - "packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts" + "packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts", + "packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts (cookie-lane 2FA completion pinned end-to-end — sign-in → twoFactorRedirect + two-factor cookie → verify-totp → session — plus lockout counting/reset/lock-at-threshold/lazy-expiry/admin-unlock; also the arming precedent: OS_AUTH_TWO_FACTOR=true must precede bootStack)" ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "new matrix item: per-method sign-in proof with both-sides gate checks and advertisement parity, grounded in the spec's plugin config + public feature registry", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-08-08", "change": "added the .well-known/openid-configuration + oauth-authorization-server discovery-document clause (issuer/endpoints match the mounted base, jwks cross-check) and self-service change-email + delete-user clauses; recorded the live-route divergences (device flow, password reset) from the spec paths (PENDING-GAPS §D)", "ref": "claude/platform-test-checklist-ocwugl" } + { "revision": 2, "date": "2026-08-08", "change": "added the .well-known/openid-configuration + oauth-authorization-server discovery-document clause (issuer/endpoints match the mounted base, jwks cross-check) and self-service change-email + delete-user clauses; recorded the live-route divergences (device flow, password reset) from the spec paths (PENDING-GAPS §D)", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 3, "date": "2026-08-20", "change": "scoped scan-functionality (扫描功能) sweep: the 2FA clause asserted the interrupt but cited no pin — added packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts to source and noted in the clause verify + variant row that cookie-lane completion is pinned end-to-end by that test (cite per rule 6 instead of re-deriving). Enrollment lifecycle around the gate now owned by the four new identity-auth.two-factor-* items. No restructuring", "ref": "claude/new-session-0pv25p" } ] }, { @@ -1229,6 +1231,348 @@ { "revision": 1, "date": "2026-08-08", "change": "new item: admin CSV identity import with password-policy matrix (auto/temporary/invite/none), imported-user sign-in, upsert idempotency, response-only one-time passwords, non-admin denied, grounded in objectui identityImport.ts + admin-user-endpoints.ts (PENDING-GAPS §G)", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-08-11", "change": "from run #7663: recorded the fixture note that the `auto` policy's temporary-fallback branch cannot occur on `objectstack dev` — both transports always register (log fallback) and dev pins NODE_ENV='development', so neither deliverability gate can be made false and every row takes the invite path. The clause now says score the invite half and record the fallback half as not-exercised-by-fixture rather than as a defect, and names the boot that WOULD exercise it. Recorded so the next sweep does not re-derive it", "ref": "#7740" } ] + }, + { + "id": "identity-auth.two-factor-enrollment-reveal", + "title": "TOTP enrollment reveal: /two-factor/enable returns a parseable otpauth:// URI + backup codes, the declared reveal dialog's paths resolve against that exact shape, and the live re-reveal endpoint's behavior is recorded", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["a signed-in user with a known password enrolling their own second factor"], + "fixtures": { + "app": "showcase", + "requires": [ + "ARMING, decided BEFORE boot: twoFactor defaults FALSE (`twoFactorFromEnv ?? pluginConfig.twoFactor ?? false`, packages/plugins/plugin-auth/src/auth-manager.ts:2143 and again at the features block ~:4086) and nothing in examples/ turns it on — set OS_AUTH_TWO_FACTOR=true before bootStack/the server starts (the plugin list is resolved ONCE at auth-manager construction; setting it after boot does nothing — packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts:122-128 is the precedent), or flip the Setup mfa_required toggle, which forces plugins.twoFactor on so gated users can comply (packages/plugins/plugin-auth/src/auth-plugin.ts:1302-1315, ADR-0069 D3)", + "a signed-in user with a known password (the lockout pin enrolls the seeded dev admin, password admin123)", + "TOTP codes: copy the hand-rolled RFC 6238 helper (base32Decode + totp, node:crypto only) from two-factor-lockout.dogfood.test.ts:74-106 — no otplib/speakeasy dependency exists anywhere in this repo and none may be added; better-auth's defaults are the RFC's (SHA-1, 6 digits, 30s)" + ], + "knownGaps": [ + "BROWSER LANE BLOCKED(dependency): the QR-bearing reveal lives on sys_two_factor.enable_two_factor (resultDialog {path:'totpURI',format:'qrcode'} + {path:'backupCodes',format:'code-list'} + acknowledge gate — packages/platform-objects/src/identity/sys-two-factor.object.ts:61-83), but sys_two_factor is mounted in NO app (account.app.ts's tab list stops at sys_oauth_application), so there is no navigable surface that opens that dialog. The navigable variant, sys_user.enable_two_factor (sys-user.object.ts:401-416), declares NO resultDialog — only a static successMessage telling the user to scan a QR that is never rendered, and the source comment at :395-400 says outright the generic action engine can't render it yet. The resultDialog renderer contract itself is a SHOULD addressed to objectui with zero in-repo consumer (packages/spec/src/ui/action.zod.ts:1089-1145). Until an app mounts sys_two_factor or objectui ships the renderer, drive this item over the API and score the browser lane blocked(dependency) — never pass a QR clause on the strength of the successMessage text" + ] + }, + "steps": [ + "arm 2FA BEFORE boot (OS_AUTH_TWO_FACTOR=true exported first, or the Setup mfa_required path) — see fixtures; the in-process bootStack harness the lockout pin uses is the workable API-lane rig, since sys_two_factor's REST surface is get-by-id only", + "GUARD (assert-armed, first clause): GET /api/v1/auth/config and require features.twoFactor true; probe POST /api/v1/auth/two-factor/enable with an empty body and require a non-404 answer (400/401 is armed; 404 means the catch-all never mounted the two-factor family and NOTHING below may be scored)", + "sign in as the enrolling user and POST /api/v1/auth/two-factor/enable with { password }; capture the full response body", + "parse the returned totpURI: it must be an otpauth://totp/ URI whose label identifies the issuer + account (the app/user), whose secret query param base32-decodes cleanly (use the copied helper), and whose algorithm/digits/period params — where present — are the sane RFC defaults (SHA1/6/30)", + "assert backupCodes is a non-empty array of strings and record its size/format", + "cross-check the declared reveal contract against the live shape: sys_two_factor.enable_two_factor's resultDialog.fields[].path values ('totpURI', 'backupCodes' — sys-two-factor.object.ts:78-81) are dot paths into result.data (action.zod.ts resultDialog contract); confirm each resolves against the response the API actually returned", + "probe the re-reveal endpoint: POST /api/v1/auth/two-factor/get-totp-uri (POST — auth-route-ledger.ts:374; the mounted-surface list is publication-not-liveness per #7735, so drive it, don't infer) with { password }, and record the actual behavior: status, whether it demands the password, and whether it returns the SAME secret as enable did — the 'shown only once' promise in the enable dialog copy (sys-two-factor.object.ts:76) does not account for this endpoint existing", + "observe-and-flag the navigable surface: read the sys_user action metadata (meta surface) and record that enable_two_factor there carries no resultDialog — the toast-only reveal is a recorded product gap, not a clause to pass or fail this run on", + "negative lane: POST /two-factor/enable with a WRONG password (refused, no sys_two_factor row created) and as an anonymous caller (401)" + ], + "acceptance": [ + { + "clause": "ASSERT-ARMED GUARD — the surface exists before anything is scored: /api/v1/auth/config advertises features.twoFactor true and POST /two-factor/enable answers non-404. Without OS_AUTH_TWO_FACTOR=true (or the mfa_required forcing path) set BEFORE boot, the two-factor family is simply absent from the catch-all, and every clause below would pass green against a 404 surface", + "oracle": "api", + "verify": "GET /api/v1/auth/config features.twoFactor === true, plus a probe POST to /api/v1/auth/two-factor/enable returning 400/401 (not 404). If this clause fails the run stops here: the remaining clauses are blocked(environment), never pass", + "evidence": "the /auth/config read + the probe status" + }, + { + "clause": "enable returns a parseable enrollment: the response carries totpURI as an otpauth://totp/ URI whose label names issuer + account, whose secret base32-decodes (the copied RFC 6238 helper accepts it), and whose algorithm/digits/period are absent-or-sane (better-auth defaults SHA-1/6 digits/30s)", + "oracle": "api", + "verify": "parse the URI (the lockout pin's URL trick at two-factor-lockout.dogfood.test.ts:183 — rewrite otpauth:// to https:// and read searchParams); base32Decode(secret) throws on any invalid character, so a clean decode is the assertion", + "evidence": "the enable response + the parsed URI components (secret value redacted to its length)" + }, + { + "clause": "backupCodes is a non-empty array of strings in the SAME response — the one-shot reveal has something to reveal", + "oracle": "api", + "verify": "the enable response's backupCodes array is non-empty; record count and shape (never the codes themselves in the run issue)", + "evidence": "the response shape (codes redacted, count recorded)" + }, + { + "clause": "the declared reveal dialog points at paths the API actually returns: both resultDialog.fields[].path values on sys_two_factor.enable_two_factor ('totpURI', 'backupCodes') resolve as dot paths against the live enable response — a reveal dialog aimed at a path the API doesn't return renders an empty box, and because the reveal is one-shot the user silently loses their only copy of the secret and codes", + "oracle": "api", + "verify": "for each declared path, response[path] is defined and of the format-appropriate type (string for qrcode, string[] for code-list). The contract that paths address result.data is packages/spec/src/ui/action.zod.ts (resultDialog block, :1089-1145)", + "evidence": "the path-by-path resolution table against the captured response" + }, + { + "clause": "OBSERVE-AND-FLAG — the live re-reveal endpoint is recorded, not assumed: POST /api/v1/auth/two-factor/get-totp-uri is a mounted route that can hand the totpURI back AFTER enrollment, which the dialog's 'shown only once' copy does not account for. The run records its actual behavior (password-gated? same secret re-revealed? works before and after verification?) — this clause is satisfied by an accurate record, not by any particular outcome", + "oracle": "api", + "verify": "drive POST /two-factor/get-totp-uri with and without the correct password, before and after the enrollment is verified; record each status and whether the returned URI carries the same secret. If it re-reveals with NO password gate, that is an authentication finding — RUNNER rule 2's carve-out applies (existence published, recipe withheld)", + "evidence": "the recorded behavior table (secrets redacted)" + }, + { + "clause": "OBSERVE-AND-FLAG — the only navigable enrollment surface renders no QR: sys_user.enable_two_factor declares no resultDialog, only a successMessage instructing the user to scan a QR the engine never draws (the source's own admission, sys-user.object.ts:395-400). Recorded as the standing product gap that keeps the browser lane blocked — never scored as a pass", + "oracle": "api", + "verify": "the meta read of sys_user's actions shows enable_two_factor with successMessage and no resultDialog, while sys_two_factor (mounted nowhere) carries the real reveal dialog", + "evidence": "the two action-metadata reads" + } + ], + "negative": [ + "enable with a wrong password succeeding — or an anonymous enable answering 2xx — is a FAIL: the password check is the proof-of-presence that stops a stolen cookie from enrolling an attacker's authenticator", + "scoring ANY clause with the guard clause unmet is the false positive this item was authored against: an unarmed boot 404s the whole family, and a runner who skips the guard ticks green boxes on an absent surface (absence-inference, both directions)", + "if get-totp-uri re-reveals the secret with no password gate, do NOT publish the reproduction — authentication carve-out, RUNNER rule 2: item + clause + 'detail withheld pending maintainer'" + ], + "traps": ["dispatcher-vs-hono-route", "absence-inference"], + "source": [ + "packages/plugins/plugin-auth/src/auth-manager.ts:2143 (twoFactor: twoFactorFromEnv ?? pluginConfig.twoFactor ?? false — resolved once at construction) + ~:4086 (features.twoFactor, same resolution)", + "packages/plugins/plugin-auth/src/auth-route-ledger.ts:214 (POST /two-factor/enable, SDK row) + :374 (POST /two-factor/get-totp-uri in BETTER_AUTH_MOUNTED_SURFACE — publication, not liveness, #7735)", + "packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts (endpoint EXISTENCE is already pinned at LEDGERED_PLUGIN_CONFIG twoFactor:true — behavior is this item's gap, not existence)", + "packages/platform-objects/src/identity/sys-two-factor.object.ts:61-83 (enable_two_factor resultDialog: totpURI qrcode + backupCodes code-list + acknowledge; :76 the 'shown only once' copy)", + "packages/platform-objects/src/identity/sys-user.object.ts:395-416 (the navigable variant: no resultDialog, successMessage only; :395-400 the engine-can't-render-it-yet admission)", + "packages/spec/src/ui/action.zod.ts:1089-1145 (resultDialog: dot paths into result.data; renderer contract is a SHOULD to objectui, no in-repo consumer)", + "packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts:74-106 (the hand-rolled RFC 6238 helper to copy — no OTP dependency exists or may be added) + :122-128 (arming must precede bootStack) + :178-185 (enable → totpURI → secret extraction precedent)", + "packages/platform-objects/src/apps/account.app.ts (mounts sys_inbox_message/sys_member/sys_account/sys_session/sys_api_key/sys_oauth_application — sys_two_factor absent, which is why the browser lane is blocked)" + ], + "history": [ + { "revision": 1, "date": "2026-08-20", "change": "authored in the scoped scan-functionality (扫描功能) coverage sweep: the TOTP 'scan the QR' enrollment lifecycle had only its challenge gate covered (one clause of identity-auth.auth-method-matrix). This item takes the reveal half: enable's { totpURI, backupCodes } shape, the resultDialog path contract, the get-totp-uri re-reveal probe, and the recorded no-QR gap on the only navigable surface", "ref": "claude/new-session-0pv25p" } + ] + }, + { + "id": "identity-auth.two-factor-verify-to-activate", + "title": "Enrollment is inert until one correct TOTP verifies on the session lane — and the run records what sys_two_factor.verified actually carries in between", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["a signed-in user mid-enrollment (session lane)", "the same user at the sign-in challenge (cookie lane)"], + "fixtures": { + "app": "showcase", + "requires": [ + "2FA armed BEFORE boot (OS_AUTH_TWO_FACTOR=true precedes bootStack, or the Setup mfa_required toggle forces plugins.twoFactor on — see identity-auth.two-factor-enrollment-reveal's fixtures for the full arming note; every clause here sits behind the same assert-armed guard)", + "an in-process harness (bootStack + an ObjectQL system read, exactly the lockout pin's rig) — sys_two_factor's REST surface is get-by-id only (apiMethods ['get']), so reading the enrollment row's verified flag needs the system-context read, not a REST list", + "TOTP codes from the copied RFC 6238 helper (two-factor-lockout.dogfood.test.ts:74-106); no OTP dependency may be added" + ], + "knownGaps": [ + "browser lane blocked(dependency) — same standing gap as identity-auth.two-factor-enrollment-reveal: no app mounts sys_two_factor and the sys_user surface renders no QR, so the verify-to-activate walk is API-lane only until objectui ships the reveal/verify UI" + ] + }, + "steps": [ + "arm, boot, run the assert-armed guard (features.twoFactor true, /two-factor/enable non-404) — nothing below is scored without it", + "sign in, POST /two-factor/enable { password }, decode the secret from the totpURI (the copied helper)", + "IMMEDIATELY read the sys_two_factor row via the system-context ObjectQL read and record the literal value of `verified` — this is the latent-default probe (see the clause), captured before anything else touches the row", + "before any verification, drive the cookie lane: POST /auth/sign-in/email with the password, record whether twoFactorRedirect interposes, then attempt POST /two-factor/verify-totp through the two-factor cookie with a CORRECT code from the new secret — better-auth's documented posture is that the sign-in path refuses an unverified enrolment (TOTP_NOT_ENABLED) before any lockout bookkeeping (the lockout pin's own comment, two-factor-lockout.dogfood.test.ts:187-190); record the actual status/code", + "complete enrollment on the SESSION lane: with the live session (no two-factor cookie), POST /two-factor/verify-totp { code } — this call is isSignIn:false, deliberately touches no lockout counter (test :23-30), and 200s (the setup precedent at :191-194, until now cited by no checklist item)", + "re-read the row: verified must now be true; sys_user.two_factor_enabled true", + "prove activation on the cookie lane: a fresh password sign-in stops at the challenge (twoFactorRedirect: true + two-factor cookie, test :223-230) and a correct code through that cookie completes into a session — NOTE completing enrollment/sign-in rotates the session token (test :375-377), so refresh your bearer before any follow-up calls or a stale-token 401 will read like a defect", + "negative: a wrong code on the session lane does not activate (row stays unverified; enable can be re-driven)" + ], + "acceptance": [ + { + "clause": "ASSERT-ARMED GUARD — identical to identity-auth.two-factor-enrollment-reveal clause 0 and binding for the same reason: unarmed boots 404 the family and would green-tick every clause below", + "oracle": "api", + "verify": "features.twoFactor true in /auth/config + non-404 probe; on failure everything below is blocked(environment)", + "evidence": "the config read + probe status" + }, + { + "clause": "a fresh enrollment is INERT at sign-in: between enable and the first successful verify, the new factor cannot complete a sign-in — the cookie-lane verify-totp with a correct code from the just-enabled secret is refused (better-auth's posture: an unverified enrolment draws TOTP_NOT_ENABLED before any lockout bookkeeping). The run records the exact status and error code observed, including whether the password stage even interposed a challenge for the unverified enrollment", + "oracle": "api", + "verify": "the pre-verification cookie-lane attempt with a CORRECT code returns non-2xx; record status + body error code and whether sign-in answered twoFactorRedirect at that point. The expectation's source is the lockout pin's setup comment (two-factor-lockout.dogfood.test.ts:187-190) — if the live behavior diverges, record what actually happened rather than forcing the wording", + "evidence": "the sign-in + refused-verify trace" + }, + { + "clause": "LATENT-DEFAULT PROBE (observe-and-flag, auth-integrity — NOT a confirmed defect): record which value sys_two_factor.verified actually carries immediately post-enable. The suspicion it encodes: the object declares verified with defaultValue: true (sys-two-factor.object.ts:166-170) while better-auth enrolls verified:false, and AUTH_TWO_FACTOR_SCHEMA maps only the four RENAMED fields (backupCodes/userId/failedVerificationCount/lockedUntil — auth-schema-config.ts:366-374; `verified` needs no rename, so its absence from the map is not itself the defect). IF better-auth ever omits the column on insert, the ObjectQL default would mark an unverified enrollment active. The clause is satisfied by the accurate record; verified===false post-enable is the healthy reading", + "oracle": "api", + "verify": "the system-context ObjectQL read of the row between enable and verify: capture `verified` verbatim. If it reads TRUE pre-confirmation AND clause 2's sign-in lane accepts the factor, that is an authentication-integrity finding — RUNNER rule 2's carve-out governs its publication (item + clause + detail withheld pending maintainer)", + "evidence": "the row read (timestamped between enable and verify)" + }, + { + "clause": "one correct TOTP on the SESSION lane activates: verify-totp with the live session (isSignIn:false — no lockout counter touched) returns 200, and the row now reads verified true with sys_user.two_factor_enabled true", + "oracle": "api", + "verify": "the 200 on the session-lane verify + the post-verify row read + the sys_user read (two_factor_enabled is the AUTH_TWO_FACTOR_USER_FIELDS mapping)", + "evidence": "the verify response + both row reads" + }, + { + "clause": "activation makes the challenge real: a fresh password sign-in now answers twoFactorRedirect: true with a two-factor cookie and does NOT hand out a usable session; a correct code through that cookie completes into one", + "oracle": "api", + "verify": "the post-activation sign-in trace (twoFactorRedirect + Set-Cookie) followed by the cookie-lane verify 200 returning a session token — the same shape the lockout pin's beginChallenge/verifyWithCookie drive", + "evidence": "the challenge + completion trace" + } + ], + "negative": [ + "a fresh, never-verified enrollment satisfying a sign-in challenge is an authentication FAIL of the highest class — publish under RUNNER rule 2's carve-out only (no reproduction anywhere on GitHub), and apply rule 7's independent re-derivation before acting", + "a session-lane verify that increments the lockout counter is a FAIL — the isSignIn:false path deliberately touches no budget (a user fat-fingering enrollment must not burn their sign-in lockout allowance)", + "scoring any clause on an unarmed boot — see the guard" + ], + "traps": ["dispatcher-vs-hono-route", "absence-inference"], + "source": [ + "packages/plugins/plugin-auth/src/auth-route-ledger.ts:217 (POST /two-factor/verify-totp, SDK row)", + "packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts:187-195 (the verify-to-activate SETUP precedent this item promotes to a tested contract: enrolment confirmed through the session path, TOTP_NOT_ENABLED posture for unverified enrolments) + :23-30 (isSignIn decides lockout bookkeeping) + :375-377 (completing enrolment rotates the session token)", + "packages/platform-objects/src/identity/sys-two-factor.object.ts:166-170 (verified: defaultValue TRUE — the declared default the probe interrogates)", + "packages/plugins/plugin-auth/src/auth-schema-config.ts:366-374 (AUTH_TWO_FACTOR_SCHEMA: four renamed fields, verified unmapped-because-unrenamed) + AUTH_TWO_FACTOR_USER_FIELDS (twoFactorEnabled → two_factor_enabled)", + "packages/plugins/plugin-auth/src/auth-manager.ts:2143 (arming default false, resolved once at construction)" + ], + "history": [ + { "revision": 1, "date": "2026-08-20", "change": "authored in the scoped scan-functionality (扫描功能) coverage sweep: the enable→verify→active transition existed only as uncited test SETUP in the lockout pin. Encodes the inert-until-verified contract, the session-lane/cookie-lane split, and the verified-defaultValue:true latent-default probe (observe-and-flag, worded as suspicion not fact)", "ref": "claude/new-session-0pv25p" } + ] + }, + { + "id": "identity-auth.two-factor-backup-codes", + "title": "Backup codes: revealed once at enable, spendable exactly once at the sign-in challenge, and regeneration kills the prior set — while the only navigable regenerate surface never shows the new codes", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["a user with a VERIFIED enrollment (run identity-auth.two-factor-verify-to-activate's path first)", "the same user at the sign-in challenge spending a code"], + "fixtures": { + "app": "showcase", + "requires": [ + "2FA armed BEFORE boot + a VERIFIED enrollment — this item's challenge lane only exists after identity-auth.two-factor-verify-to-activate's walk completes; run the three enrollment items as one sequence on one boot", + "the backup codes captured from the enable response (they are never legitimately retrievable later — that unretrievability is itself under test)", + "in-process system reads for the storage-shape observation (sys_two_factor REST is get-by-id only)" + ], + "knownGaps": [ + "browser lane blocked(dependency) — same standing gap as identity-auth.two-factor-enrollment-reveal: sys_two_factor's one-shot regenerate reveal dialog is mounted in no app, and the navigable sys_user surface has no reveal at all (which this item records as its own observe-and-flag clause)" + ] + }, + "steps": [ + "arm, boot, guard (assert-armed — same clause 0 as the sibling items), enable + verify the enrollment, retaining the enable response's backupCodes", + "begin a sign-in challenge (password sign-in → twoFactorRedirect: true + two-factor cookie) and POST /two-factor/verify-backup-code through the cookie with ONE retained code; capture the 200 + session", + "begin a FRESH challenge and spend the SAME code again; capture the refusal — single-use", + "spend a SECOND retained code at another fresh challenge to prove the refusal above was consumption, not a dead code family", + "as the signed-in user, POST /two-factor/generate-backup-codes { password }; capture the NEW set from the response", + "begin a fresh challenge and attempt an UNSPENT code from the OLD set; capture the refusal — regeneration invalidated the prior set wholesale", + "spend one NEW code to prove the new set is live", + "storage-shape observation: read sys_two_factor.backup_codes via the system-context read and record what the column actually carries (the declaration says JSON-serialized — sys-two-factor.object.ts:160-164; record whether the stored form is plaintext codes or an encrypted/hashed blob, without publishing either)", + "observe-and-flag the navigable surface: sys_user.generate_backup_codes (sys-user.object.ts:435-452) declares NO resultDialog — a toast only. On the only surface a user can actually reach, regenerating kills the old set and never shows the new one: a self-inflicted lockout path. The sys_two_factor variant with the one-shot reveal (sys-two-factor.object.ts:104-126) is mounted nowhere", + "negative: generate-backup-codes with a wrong password refused (old set must survive the refused attempt); anonymous call 401" + ], + "acceptance": [ + { + "clause": "ASSERT-ARMED GUARD + verified-enrollment precondition: features.twoFactor advertised, the two-factor family mounted, and the enrollment VERIFIED (an unverified enrollment never reaches a challenge, so every spend clause below would be unreachable, not passed)", + "oracle": "api", + "verify": "the /auth/config read + the sibling item's activation walk completing on this boot", + "evidence": "the config read + the activation trace reference" + }, + { + "clause": "a backup code completes the sign-in challenge: verify-backup-code through the two-factor cookie returns 200 and a usable session — the codes are a real second factor, not decoration on the enable response", + "oracle": "api", + "verify": "the challenge trace: sign-in (twoFactorRedirect) → verify-backup-code 200 → the session answers an authed call", + "evidence": "the spend trace (code value redacted)" + }, + { + "clause": "single-use: the SAME code at a fresh challenge is refused, while a different retained code still works — consumption is per-code, and the positive control proves the refusal is consumption rather than a globally dead set", + "oracle": "api", + "verify": "the same-code re-spend non-2xx + the sibling code's 200 at the next challenge", + "evidence": "the two traces" + }, + { + "clause": "regeneration invalidates the prior set wholesale: after generate-backup-codes, an UNSPENT code from the old set is refused and a code from the new set works — exactly what the regenerate dialog's own copy promises ('Previous backup codes are now invalid', sys-two-factor.object.ts:113,120)", + "oracle": "api", + "verify": "the old-unspent-code refusal + the new-code 200, both at fresh challenges after the regenerate 200", + "evidence": "the regenerate response shape + the two spend traces" + }, + { + "clause": "OBSERVE-AND-RECORD — the storage shape: what sys_two_factor.backup_codes actually holds (declared 'JSON-serialized backup recovery codes'). Plaintext codes at rest would be an authentication finding (publishable only under RUNNER rule 2's carve-out); an encrypted/hashed blob is the healthy reading. The clause is satisfied by the accurate record", + "oracle": "api", + "verify": "the system-context row read; describe the stored form (length/shape), never the value", + "evidence": "the described (not quoted) column content" + }, + { + "clause": "OBSERVE-AND-FLAG — the navigable regenerate is a lockout path: sys_user.generate_backup_codes has no resultDialog (successMessage toast only, refreshAfter:false), so the console user who regenerates has their old codes killed and the new ones never displayed. Recorded as the standing product gap (paired with the unmounted sys_two_factor reveal variant); never scored as a pass", + "oracle": "api", + "verify": "the meta read of sys_user.generate_backup_codes (no resultDialog) vs sys_two_factor.regenerate_backup_codes (one-shot reveal declared, surface unmounted)", + "evidence": "the two action-metadata reads" + } + ], + "negative": [ + "a code spendable twice is an authentication FAIL — carve-out publication only (RUNNER rule 2), reproduce twice per rule 2 before recording", + "old-set codes surviving a regeneration is a FAIL of the same class — the regenerate dialog's own copy states the invalidation", + "generate-backup-codes succeeding with a wrong password — or anonymously — is a FAIL: regeneration destroys the recovery path, so it demands the same proof-of-presence as enable/disable", + "a refused wrong-password regenerate that STILL rotated the set is a FAIL (the refusal must be a no-op — verify an old code still spends after it)" + ], + "traps": ["dispatcher-vs-hono-route", "absence-inference"], + "source": [ + "packages/plugins/plugin-auth/src/auth-route-ledger.ts:215-216 (POST /two-factor/generate-backup-codes, POST /two-factor/verify-backup-code — SDK rows)", + "packages/platform-objects/src/identity/sys-two-factor.object.ts:104-126 (regenerate_backup_codes: password param, one-shot resultDialog, 'previous codes stop working immediately') + :160-164 (backup_codes storage declaration)", + "packages/platform-objects/src/identity/sys-user.object.ts:435-452 (generate_backup_codes on the navigable surface: successMessage only, NO resultDialog — the recorded lockout path)", + "packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts (the arming + challenge-cookie rig this item reuses: beginChallenge/cookieHeader; TOTP helper :74-106)" + ], + "history": [ + { "revision": 1, "date": "2026-08-20", "change": "authored in the scoped scan-functionality (扫描功能) coverage sweep: backup codes had zero coverage — spend/single-use/regenerate-invalidates were asserted nowhere, and the no-reveal regenerate on the navigable sys_user surface (a self-lockout path) was unrecorded", "ref": "claude/new-session-0pv25p" } + ] + }, + { + "id": "identity-auth.two-factor-disable-lifecycle", + "title": "Disable tears the factor down completely — row gone, flag off, next sign-in unchallenged, stale backup codes dead — is password-gated server-side, and under mfa_required re-gates through the grace-clock machinery", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": ["a user with an ACTIVE (verified) enrollment disabling it", "the same user post-disable at a fresh sign-in", "for the mfa_required clause: the same user under an enforced-MFA boot"], + "fixtures": { + "app": "showcase", + "requires": [ + "2FA armed BEFORE boot + an ACTIVE enrollment with retained backup codes (run the enrollment items' sequence first on the same boot)", + "for the re-gate clause: a boot (or Setup patch) with mfa_required on — the toggle itself forces plugins.twoFactor on (auth-plugin.ts:1302-1315), so this variant needs no separate env arming", + "in-process system reads (sys_two_factor REST is get-by-id only; post-disable the row's ABSENCE is the assertion, which needs the system-context find)" + ], + "knownGaps": [ + "browser lane blocked(dependency) — same standing gap as the sibling two-factor items (no app mounts sys_two_factor; the sys_user disable action is navigable but the item's teardown proofs are all API/row assertions anyway)" + ] + }, + "steps": [ + "arm, boot, guard (assert-armed), establish an ACTIVE enrollment (enable → session-lane verify), retain unspent backup codes, and confirm the challenge interposes (twoFactorRedirect: true) — the BEFORE state every teardown clause diffs against", + "NEGATIVE FIRST (while the enrollment still exists to protect): POST /two-factor/disable with a WRONG password; capture the refusal, then re-prove the enrollment is intact — the row still present, a fresh sign-in still challenged", + "POST /two-factor/disable with the CORRECT password; capture the 200", + "assert teardown: the system-context find on sys_two_factor for this user returns NO row; sys_user.two_factor_enabled reads false", + "fresh password sign-in: the response is a full session with NO twoFactorRedirect and no two-factor cookie — the challenge is gone, not just the row", + "fire POST /two-factor/verify-backup-code with a retained unspent code anyway (no challenge cookie exists to carry it) and capture the refusal — stale codes are dead, not dormant", + "re-enable + verify to confirm the lifecycle is repeatable (a fresh secret, fresh codes — the old secret's TOTP codes must not verify the new enrollment)", + "mfa_required variant (separate boot or Setup patch): with enforced MFA on, disable the active enrollment and record what ACTUALLY happens next, against the source's machinery (auth-manager.ts computeAuthGate ~:5084-5160): the disable itself is NOT refused anywhere in the source; on the next request the gate sees required-but-unenrolled and answers 403 MFA_REQUIRED only once mfa_grace_period_days (default 7) have elapsed from sys_user.mfa_required_at — a stamp written LAZILY the first time the user is seen unenrolled and, as far as the source shows, never cleared on enrollment. So the post-disable posture depends on that stamp: an old elapsed stamp gates immediately; no stamp opens a fresh grace window. Record the stamp value and the observed branch", + "while gated (if the gate engaged), confirm the /two-factor/* enrollment endpoints still answer — the re-gate must leave the compliance path open (the mfa_required toggle forces the plugin on for exactly this reason, per its own comment)" + ], + "acceptance": [ + { + "clause": "ASSERT-ARMED GUARD + active-enrollment precondition, same as the sibling items — a disable 'passing' against an absent surface or an inert enrollment proves nothing", + "oracle": "api", + "verify": "features.twoFactor advertised; the pre-disable challenge trace shows twoFactorRedirect: true", + "evidence": "the config read + the BEFORE challenge trace" + }, + { + "clause": "wrong-password disable is refused SERVER-SIDE and is a no-op: the refusal is not a client-only confirm (a stolen session cookie must not be able to strip the account's second factor), and after it the row still exists and a fresh sign-in still challenges", + "oracle": "api", + "verify": "the wrong-password disable non-2xx + the intact row read + a still-challenged sign-in trace", + "evidence": "the refusal + the two intact-state proofs" + }, + { + "clause": "correct-password disable tears down completely: the sys_two_factor row for the user is GONE (not merely flagged) and sys_user.two_factor_enabled is false", + "oracle": "api", + "verify": "the disable 200 + the empty system-context find on sys_two_factor + the sys_user read", + "evidence": "the response + both reads" + }, + { + "clause": "the next password sign-in is unchallenged: a full session comes back with NO twoFactorRedirect and no two-factor cookie — the factor is removed from the sign-in path, not just from storage", + "oracle": "api", + "verify": "the post-disable sign-in response body (no twoFactorRedirect key or false) and Set-Cookie set; the session answers an authed call", + "evidence": "the sign-in trace" + }, + { + "clause": "stale backup codes are dead: a retained unspent code is refused post-disable (and after a re-enable, neither the old secret's TOTP codes nor the old backup codes verify the NEW enrollment)", + "oracle": "api", + "verify": "the post-disable verify-backup-code refusal + (post re-enroll) an old-secret TOTP and old backup code each refused at the new enrollment's challenge", + "evidence": "the refusal traces" + }, + { + "clause": "OBSERVE-AND-RECORD — the mfa_required re-gate follows the grace-clock machinery, not a hard block on disable: the source refuses the disable nowhere; re-gating is computeAuthGate answering 403 MFA_REQUIRED for a required-but-unenrolled user once the grace window from sys_user.mfa_required_at elapses, and that stamp is written lazily on first sight of non-enrollment and (per the source) never cleared afterward — so whether the gate bites immediately after a disable or opens a fresh grace window depends on the stamp the user already carries. The run records the stamp, the branch observed, and — the 'not stranded' half — that the /two-factor/* enrollment endpoints remain reachable while gated (mfa_required forces plugins.twoFactor on so gated users can comply). An accurate record satisfies the clause; a gated user whose enrollment endpoints 404 is the FAIL", + "oracle": "api", + "verify": "under an mfa_required boot: disable 200 → read sys_user.mfa_required_at → drive an authed request and record 2xx-in-grace vs 403 MFA_REQUIRED → while gated (or with the grace shrunk via mfa_grace_period_days=0), POST /two-factor/enable answers non-404. Source: auth-manager.ts computeAuthGate ~:5084-5160 (lazy stamp :5139-5152, fail-open catch) + auth-plugin.ts:1302-1315 (the forcing)", + "evidence": "the recorded branch + stamp + the enrollment-endpoint reachability probe" + } + ], + "negative": [ + "a disable that succeeds with a wrong password — or leaves the challenge alive while reporting the factor off (row gone but sign-in still demands a code, or the reverse) — is a FAIL; the wrong-password case is an account-downgrade hole and rides RUNNER rule 2's carve-out if found", + "stale backup codes (or the old secret's TOTPs) verifying anything after the disable/re-enable is an authentication FAIL — carve-out publication", + "under mfa_required, a gated user whose /two-factor/* endpoints are unreachable is stranded-not-re-gated — FAIL against the forcing comment's own contract", + "do NOT file the immediately-biting gate (old elapsed mfa_required_at stamp) as 'no grace period' — the lazy, never-cleared stamp is the mechanism the source actually implements; record it, and if the never-cleared stamp is judged wrong that is a design finding for the anchor card, not a clause fail" + ], + "traps": ["dispatcher-vs-hono-route", "absence-inference"], + "source": [ + "packages/plugins/plugin-auth/src/auth-route-ledger.ts:213 (POST /two-factor/disable, SDK row)", + "packages/platform-objects/src/identity/sys-user.object.ts:417-434 (disable_two_factor: password param, visible only while two_factor_enabled) + packages/platform-objects/src/identity/sys-two-factor.object.ts:84-102 (the sys_two_factor variant, same endpoint)", + "packages/plugins/plugin-auth/src/auth-manager.ts ~:5084-5160 (computeAuthGate: MFA_REQUIRED after the grace window; mfa_required_at stamped lazily at :5141-5147, no clearing write anywhere in the source; fail-open) + :4960 (isAuthGateActive)", + "packages/plugins/plugin-auth/src/auth-plugin.ts:1302-1315 (mfa_required forces plugins.twoFactor on — 'otherwise gated users would have no way to comply' — the not-stranded contract the last clause tests)", + "packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts (arming + challenge rig + TOTP helper precedent, as on the sibling items)" + ], + "history": [ + { "revision": 1, "date": "2026-08-20", "change": "authored in the scoped scan-functionality (扫描功能) coverage sweep: disable had zero coverage. Encodes full teardown (row/flag/challenge/stale codes), the server-side password gate with negative-first ordering, and the mfa_required re-gate clause worded against the ACTUAL source machinery (lazy never-cleared grace stamp, computeAuthGate) rather than an assumed hard block", "ref": "claude/new-session-0pv25p" } + ] } ] } diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index 30fbea93f5..8d95910578 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -1047,7 +1047,7 @@ "title": "The /api/v1/datasources admin lifecycle: static driver catalog, runtime create with provenance+health, secret never echoes (hasSecret only), bad drafts 400 DATASOURCE_ADMIN_ERROR, unwired federation degrades 503 naming external-datasource", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ @@ -1061,7 +1061,7 @@ "a crypto provider / secret binder (createDatasourceSecretBinder) so the inline secret is bound, not stored cleartext" ], "knownGaps": [ - "the external-datasource FEDERATION service is intentionally NOT wired in the admin-lifecycle boot — its absence is what the 503-naming clause verifies; a boot that DOES wire it should record that and skip the 503 clause as not-applicable-this-run" + "STALE claim corrected (rev 2): the federation service is NO LONGER absent from a stock boot — packages/cli/src/commands/serve.ts:2966-2979 wires ExternalDatasourceServicePlugin UNCONDITIONALLY on every os dev / os serve boot (best-effort dynamic import, guarded only against double registration), and :2984-2993 additionally wires createExternalValidationPlugin. On a normal boot the 503-naming clause is therefore not-applicable-live: a working 2xx from remote-tables is the WIRED happy path (covered by integration-system.external-schema-introspection), never a regression against this item. Reaching the 503 needs a boot that genuinely lacks the service — @objectstack/service-datasource uninstalled so serve.ts's dynamic import fails (its catch swallows module-not-found), or a custom host that mounts registerDatasourceAdminRoutes without the federation plugin — or the unit pin (admin-routes.test.ts, the #4225 attribution cases with the service deliberately absent)" ] }, "steps": [ @@ -1071,7 +1071,7 @@ "GET /api/v1/datasources; confirm qa_ds_probe appears with origin:'runtime' and a health field", "GET /api/v1/datasources/qa_ds_probe; inspect the body for config + a hasSecret flag and confirm the cleartext secret value is ABSENT", "POST /api/v1/datasources with a bad draft (invalid config shape / missing required); capture status + code", - "GET /api/v1/datasources/qa_ds_probe/remote-tables (an external-datasource-served route) on the boot with federation UNWIRED; capture the 503 + which service its message names", + "GET /api/v1/datasources/qa_ds_probe/remote-tables (an external-datasource-served route): on a stock os dev boot federation IS wired (serve.ts:2966-2979) so expect a WIRED answer (2xx, or a 400 EXTERNAL_DATASOURCE_ERROR from the introspector — not a 503) and score the 503 clause via its unit pin; only a boot that genuinely lacks the service (dynamic import failed / custom host without the plugin) shows the 503 — capture which service the answer names either way", "GET /api/v1/datasources/does-not-exist; capture the 404" ], "acceptance": [ @@ -1100,10 +1100,10 @@ "evidence": "the 400 response" }, { - "clause": "an unwired federation service degrades 503 naming external-datasource: the introspection routes (/:name/remote-tables, /:name/test, /:name/object-draft) answer 503 SERVICE_UNAVAILABLE whose message names the external-datasource service — NOT datasource-admin (the #4225 mis-attribution the resolve() helper exists to prevent, since datasource-admin itself is running fine)", - "oracle": "api", - "verify": "the remote-tables 503 message names 'external-datasource', not 'datasource-admin'", - "evidence": "the 503 response" + "clause": "WHEN federation is genuinely unwired, the introspection routes (/:name/remote-tables, /:name/test, /:name/object-draft) degrade 503 SERVICE_UNAVAILABLE whose message names the external-datasource service — NOT datasource-admin (the #4225 mis-attribution the resolve() helper exists to prevent, since datasource-admin itself is running fine). NOT REACHABLE on a stock os dev / os serve boot (rev 2): serve.ts:2966-2979 wires ExternalDatasourceServicePlugin unconditionally, so live remote-tables answers as a wired route there — a 2xx on a stock boot is the happy path (integration-system.external-schema-introspection), never a regression against this clause", + "oracle": "test", + "verify": "admin-routes.test.ts #4225 cases (external-datasource deliberately absent → 503 'The external-datasource service is not available.' while datasource-admin routes still serve); a live wired-boot 2xx scores this clause not-applicable-live, not fail. Live 503 evidence only from a boot that genuinely lacks the service (see knownGaps)", + "evidence": "the unit-test output (or the 503 response on a deliberately unwired boot)" }, { "clause": "unknown name → 404 RESOURCE_NOT_FOUND; and (FINDING) the /api/v1/datasources admin CRUD is UNLEDGERED — absent from packages/rest/src/rest-route-ledger.ts (only the /datasources/:name/external/* federation routes are ledgered there), a tranche-3 route-ledger discipline gap the run must record (PENDING-GAPS §E)", @@ -1139,6 +1139,210 @@ "date": "2026-08-08", "change": "new — the external-datasource ADMIN lifecycle (driver catalog, runtime create + provenance/health, secret-never-echoes, 400/503 per-service attribution, unledgered-mount finding); distinct from external-datasource-federated-read (which tests the seeded read-only fixture's query path) — cross-referenced, not duplicated. Per PENDING-GAPS §B/§E (#4225/#4249)", "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-08-20", + "change": "scoped scan-functionality (扫描功能) sweep: the knownGap's claim that the federation service is 'intentionally NOT wired in the admin-lifecycle boot' went STALE — serve.ts:2966-2979 now wires ExternalDatasourceServicePlugin unconditionally on every os dev / os serve boot (and :2984-2993 wires createExternalValidationPlugin). Rewrote the knownGap to the wired truth, re-sited the 503 clause onto its unit pin (oracle api → test) with live scoring not-applicable on a stock boot so a working 2xx is never mis-scored as a regression, reworded step 7 to match, and pointed the wired-boot introspection happy path at the new integration-system.external-schema-introspection item", + "ref": "claude/new-session-0pv25p" + } + ] + }, + { + "id": "integration-system.external-schema-introspection", + "title": "External-datasource schema introspection happy path: remote-tables lists live tables with columnCount (?schema= honoured since #7955), object-draft renders a reviewable *.object.ts, both route spellings answer as one operation behind the platform auth floor", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "api", + "personas": [ + "admin — authenticated AND holding manage_platform_settings (the admin spelling's capability gate, #9391/#9593)", + "a second authenticated persona holding NO capability, plus an anonymous probe (for the auth-floor clause)" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "the shipped read-only SQLite external datasource showcase_external (examples/app-showcase/src/system/datasources/showcase-external.datasource.ts) with its boot-provisioned fixture DB (external-fixture.ts — customers/orders, 5 columns each)", + "os dev wiring: ExternalDatasourceServicePlugin AND registerDatasourceAdminRoutes are both wired unconditionally by packages/cli/src/commands/serve.ts (:2966-2979, :3013+), so both route spellings are live on a stock boot" + ], + "knownGaps": [ + "live ?schema= NARROWING is not observable on the stock fixture: SQLite introspection returns bare, unqualified table names (sql-driver.ts:9661 introspectSchema reads sqlite_master), so no table carries an attributable schema and the service filter (external-datasource-service.ts:137 — skips only when `tableSchema && tableSchema !== opts.schema`) has nothing to exclude. A live ?schema= therefore returns the SAME full set as no filter — indistinguishable from the pre-#7955 dropped-query bug — so the narrowing clause is scored by the pinned twin-equivalence test (schema-qualified postgres fake), never by the live SQLite read; a schema-qualified (postgres) external fixture would make it live-scoreable", + "the stock fixture's clean text/number/date columns may legitimately produce an EMPTY draft.review[] (notes fire only for unrecognised or lossy type mappings, external-datasource-service.ts:216-228) — an empty array is a pass, not a missing feature" + ] + }, + "steps": [ + "boot showcase isolated via os dev; sign in as the admin holding manage_platform_settings; also mint an authenticated session holding no capability", + "GET /api/v1/datasources/showcase_external/remote-tables — record the table set with per-table columnCount", + "repeat with ?schema=main and with a repeated ?schema=a&schema=b — record status + set for each (see knownGaps for what SQLite can and cannot show here)", + "GET /api/v1/datasources/showcase_external/external/tables (the twin spelling) — compare the set against step 2", + "POST /api/v1/datasources/showcase_external/object-draft with body { \"table\": \"customers\" }, and the twin POST /api/v1/datasources/showcase_external/external/tables/customers/draft with body {} — record both drafts", + "write draft.source to a scratch file and typecheck it (it imports only a type from @objectstack/spec/data) — the compilable-*.object.ts check", + "run the pinned suites: packages/rest/src/remote-tables-twin.equivalence.test.ts (the #7955 request-shape cases + the #9686/#9593 admission cases)", + "replay remote-tables + object-draft with NO credential (expect 401 on BOTH spellings), then as the no-capability session (expect 403 on the ADMIN spelling only — the federation spelling serves, the pinned #9593 divergence)" + ], + "acceptance": [ + { + "clause": "remote-tables lists the real remote tables with a per-table columnCount: GET /api/v1/datasources/showcase_external/remote-tables → 200 with customers and orders, columnCount 5 each (the fixture DDL — external-fixture.ts CUSTOMER_TABLE/ORDER_TABLE)", + "oracle": "api", + "verify": "the response body's tables[] against the fixture tables; columnCount matches the 5-field DDL of each", + "evidence": "the remote-tables response" + }, + { + "clause": "?schema= is HONOURED, not silently dropped — the #7955 fix (assert the FIXED behavior; do not re-file the old bug): ?schema= narrows to that remote schema, an empty/absent ?schema= is no filter, a repeated ?schema=a&schema=b reaches the handler as an array and degrades to NO FILTER (never a 500, never filtering by an arbitrary one of the two) — identically on both spellings", + "oracle": "test", + "verify": "remote-tables-twin.equivalence.test.ts '#7955' describe: ?schema=public narrows to public.* on both spellings, absent/empty = unfiltered, repeated key = unfiltered, all statuses equal. Live on the SQLite fixture assert only the degradation contract (?schema= and repeated ?schema both answer 200 with the full set) and record that live narrowing is unobservable here (knownGaps)", + "evidence": "the test run output + the live ?schema= responses" + }, + { + "clause": "object-draft returns a reviewable, compilable draft: 200 with draft.definition (name, label, datasource, external.remoteName, fields incl. primaryKey from the introspected PK) and draft.source — a *.object.ts module whose FIRST line is exactly '// Generated by `os datasource introspect` (ADR-0015). Review before committing.' (external-datasource-service.ts:465) and which typechecks — plus a draft.review[] array whose entries flag unrecognised/lossy type mappings (may be empty on the clean fixture)", + "oracle": "api", + "verify": "the draft body fields + the source header string + a scratch typecheck of draft.source; review[] present as an array", + "evidence": "the draft response + the typecheck output" + }, + { + "clause": "the two route spellings stay one operation: GET /datasources/:name/remote-tables ≡ GET /datasources/:name/external/tables and POST /datasources/:name/object-draft {table} ≡ POST /datasources/:name/external/tables/:remote/draft — same service methods (IExternalDatasourceService.listRemoteTables / generateObjectDraft), same sets/drafts, same 400 EXTERNAL_DATASOURCE_ERROR refusal contract (#4249/#4264)", + "oracle": "test", + "verify": "remote-tables-twin.equivalence.test.ts (named as the twin pin at external-datasource-routes.ts:126) — plus a live spot-check that both spellings return the same table set on showcase_external", + "evidence": "the test run output + the paired live reads" + }, + { + "clause": "the auth floor holds, with its documented asymmetry: an anonymous caller is refused 401 UNAUTHENTICATED on BOTH spellings (admin: requireDatasourceAdmin/#9391; federation: refuseAnonymous/#9686 — fail-closed, before any service lookup so an anonymous probe cannot learn what is wired); an authenticated caller WITHOUT manage_platform_settings is refused 403 PERMISSION_DENIED naming the capability on the ADMIN spelling only, while the federation spelling serves — a DELIBERATE, pinned divergence (#9593), not a hole to file", + "oracle": "api", + "verify": "the four probes of step 8 against admin-routes.ts:361-420 (401 then capability 403) and external-datasource-routes.ts refuseAnonymous (401 only, capability check deliberately absent per its #9593 note); the divergence case is pinned in the twin test's 'WHO may ask' describe", + "evidence": "the 401/403/200 responses" + } + ], + "negative": [ + "an introspection-route refusal carrying DATASOURCE_ADMIN_ERROR — or a 503 naming datasource-admin — is the #4249/#4225 mis-attribution regressed: FAIL", + "a 200 to an anonymous caller on either spelling is a security FAIL (RUNNER rule 2's authz carve-out governs the report)", + "POST /:name/object-draft without body.table → 400 'Body field \"table\" is required.' (admin-routes.ts:616), never a 500", + "a 500 on any ?schema= shape (including the repeated key) is a FAIL against the degrade-to-no-filter contract both spellings pin" + ], + "traps": [ + "dispatcher-vs-hono-route", + "stale-dist" + ], + "automated": { + "kind": "unit", + "ref": "packages/rest/src/remote-tables-twin.equivalence.test.ts (#7955 request-shape + #9686/#9593 admission, driven through the real HonoHttpServer over the real ExternalDatasourceService) + packages/services/service-datasource/src/__tests__/admin-routes.test.ts — the LIVE-mount half (real showcase fixture, real sqlite introspection) is not pinned, drive os dev for it" + }, + "source": [ + "packages/services/service-datasource/src/admin-routes.ts:517-560 (remote-tables + the #7955 coercion comment), :611-623 (object-draft), :361-420 + :261 (requireDatasourceAdmin — 401 floor then manage_platform_settings 403, #9391/#9593)", + "packages/rest/src/external-datasource-routes.ts:26-28, :215-260 (the twin family GET /external/tables + POST .../draft; anonymous-deny floor #9686; capability gate deliberately absent — the file's own #9593 note)", + "packages/services/service-datasource/src/external-datasource-service.ts:137 (listRemoteTables schema filter + allowedSchemas), :183 (generateObjectDraft), :445-481 (renderObjectSource; :465 the generated header)", + "packages/drivers/driver-sql/src/sql-driver.ts:9661 (introspectSchema; SQLite branch reads sqlite_master → bare unqualified table names)", + "examples/app-showcase/src/system/datasources/ (showcase-external.datasource.ts + external-fixture.ts)", + "SURFACE NOTE (why api, not mixed): admin-routes.ts:517's comment names a Studio 'sync objects' consumer, but no such consumer was located — the only live callers found are the two CLI commands os datasource list-tables / os datasource introspect (packages/cli/src/commands/datasource/list-tables.ts, introspect.ts — both call the /external/tables spellings), covered by cli.datasource-introspect-codegen (authored in this same sweep). Same #9386/#9417 correction as external-datasource-federated-read rev 2" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-20", + "change": "new item from the scoped scan-functionality (扫描功能) coverage sweep: the introspection HAPPY PATH was uncovered — datasource-admin-lifecycle proves only the unwired-503 arm (and since serve.ts:2966 wires federation unconditionally, that arm is unit-pin territory on a stock boot). Covers remote-tables + object-draft on the shipped SQLite fixture, the #7955 ?schema= fix (asserted as fixed, live-limited on SQLite — see knownGaps), twin-spelling equivalence, and the #9391/#9686/#9593 auth floor incl. its pinned divergence", + "ref": "claude/new-session-0pv25p" + } + ] + }, + { + "id": "integration-system.external-schema-drift-gate", + "title": "Boot-time external-schema drift gate (ADR-0015 §5.2 Gate 2): onMismatch defaults to 'fail' and refuses boot naming the object and mismatched column; 'warn' proceeds logging the diff; 'ignore' is silent; a partial metadata read withholds the all-clear instead of faking one", + "since": "v16", + "status": "active", + "revision": 1, + "priority": "P1", + "surface": "cli", + "personas": [ + "operator — boots os dev / os serve and reads the boot log / exit; no in-app persona needed" + ], + "fixtures": { + "app": "showcase", + "requires": [ + "serve.ts wires createExternalValidationPlugin unconditionally in the ADR-0015 federation block (packages/cli/src/commands/serve.ts:2984-2993), so the kernel:ready gate runs on every stock boot where @objectstack/runtime + the federation service import cleanly", + "the shipped external datasource + fixture DB (showcase_external — IN SYNC by construction, provisioned idempotently each boot by external-fixture.ts)" + ], + "knownGaps": [ + "NO stock drift fixture exists — drift must be INDUCED, and only in a scratch copy: the fixture DB is re-provisioned in-sync each boot, and showcase_external deliberately declares onMismatch:'warn' (showcase-external.datasource.ts:40) precisely so drift can never brick the stock showcase. To induce: work in a scratch app/worktree copy, alter the fixture DB between boots (prefer a column TYPE change — the idempotent provisioning initObjects re-runs at every boot and may heal an added/dropped column; verify the induced drift survives the re-run before scoring) or bind a scratch federated object to a missing/mismatched table. Without an induced drift, the live arms of clauses 1-3 are blocked(fixture) and the unit pins are the fallback evidence", + "the 'fail'-DEFAULT arm additionally needs a datasource that OMITS external.validation.onMismatch (the `?? 'fail'` default, external-validation-plugin.ts:331; the metadata-read-failure catch at :333 also defaults 'fail') — stock showcase never exercises it live", + "clause 5's partial-read arm (a degraded metadata loader) is not reasonably inducible live — it is scored via its pin (list-diagnosed-consumer-sweep.test.ts), declared here rather than pretending a loader-outage fixture exists" + ] + }, + "steps": [ + "boot stock showcase; capture the in-sync all-clear info line '[external-validation] all federated objects match their remote schema' with its objects count", + "in a scratch copy, induce a surviving drift (see knownGaps) under the stock 'warn' policy; boot; capture the '[external-validation] external schema drift' warn (datasource/object/diffs) and that boot COMPLETES", + "same drift with the scratch datasource's external.validation.onMismatch OMITTED (and again with explicit 'fail'); boot; capture the abort — ExternalSchemaMismatchError, message 'Object '' does not match its remote table on datasource '':' plus per-diff lines naming the column", + "same drift under onMismatch:'ignore'; boot; confirm completion with NO drift warn", + "set external.validation.checkOnBoot:false on the drifted scratch datasource and boot: the kernel:ready scan STILL runs (checkOnBoot has no runtime consumer) — record the declared≠enforced finding, do not score the scan-running as a bug", + "declare external.validation.checkIntervalMs on a datasource and confirm the armed-timer log '[external-validation] armed background drift check' (the event-emission arm is unit-pinned)", + "run the pins: packages/runtime/src/external-validation-plugin.test.ts and the '#6504 … boot gate' describe in packages/runtime/src/list-diagnosed-consumer-sweep.test.ts" + ], + "acceptance": [ + { + "clause": "the DEFAULT is 'fail' and it refuses boot: with drift present and onMismatch undeclared (or explicit 'fail'), boot aborts with ExternalSchemaMismatchError whose message names the object, the datasource, and the mismatched column per diff line (renderDiffMessage) — the highest-consequence branch here: a mis-typed federation must not come up serving garbage", + "oracle": "log", + "verify": "the induced-drift boot's abort output against external-errors.ts:155-183; the default pinned at external-validation-plugin.ts:331 (and :333 — an unreadable datasource definition ALSO defaults 'fail'). Unit fallback: external-validation-plugin.test.ts 'throws … default (fail) policy' + 'defaults to fail when the datasource definition is unavailable'", + "evidence": "the boot abort log (or the unit-test output when the live arm is blocked(fixture))" + }, + { + "clause": "'warn' proceeds and logs: the same drift under onMismatch:'warn' (the stock showcase policy) completes boot and logs '[external-validation] external schema drift' carrying datasource, object, and the structured diffs", + "oracle": "log", + "verify": "boot completes (server answers /ready) AND the warn line is present with the diff payload", + "evidence": "the boot log excerpt + the completed-boot proof" + }, + { + "clause": "'ignore' is silent: the same drift under onMismatch:'ignore' completes boot with no drift warn at all (external-validation-plugin.ts:215)", + "oracle": "log", + "verify": "grep the boot log — no '[external-validation]' drift warn; boot completed", + "evidence": "the (absence in the) boot log + completed-boot proof" + }, + { + "clause": "checkOnBoot is DECLARED-but-UNENFORCED — assert the actual behavior and record the gap: the spec declares validation.checkOnBoot defaulting true (datasource.zod.ts:313) and docs/liveness claim it gates the boot scan, but the plugin reads only onMismatch/checkIntervalMs (its DatasourceDef, external-validation-plugin.ts:124-133; no consumer of checkOnBoot exists under packages/ outside spec + test fixtures) — so with checkOnBoot:false the kernel:ready scan STILL runs. The run must verify still-runs and record the declared≠enforced finding (ADR-0049 enforce-or-remove shape) — neither re-filing 'checkOnBoot broken' without checking for an existing card, nor scoring the scan-running as a regression", + "oracle": "log", + "verify": "boot the drifted scratch fixture with checkOnBoot:false — the gate still warns/aborts per onMismatch; run record carries the finding", + "evidence": "the boot log + the recorded finding" + }, + { + "clause": "partial-read honesty (#6504): when the federated-object listing was degraded, the gate WITHHOLDS the clean all-clear and warns that it 'swept an INCOMPLETE object set … the onMismatch gate could not have fired for them' (external-validation-plugin.ts:113-121) — silence and nothing-to-find are never conflated; a throwing verdict probe reports 'could not be determined', never a fabricated failure; a complete read still gets the plain all-clear info line", + "oracle": "test", + "verify": "list-diagnosed-consumer-sweep.test.ts:334-380 ('the ADR-0015 boot gate must not announce an all-clear over an incomplete sweep') — all four cases; live induction of a loader outage is out of scope (knownGaps)", + "evidence": "the test run output" + }, + { + "clause": "the periodic re-check is observational, never fatal: a datasource declaring external.validation.checkIntervalMs arms an unref'd background timer that emits one external.schema.drift kernel event per drifted object and never throws or aborts the process (external-validation-plugin.ts:239-317)", + "oracle": "test", + "verify": "external-validation-plugin.test.ts 'background drift detection' describe (one event per drifted object, cross-datasource isolation); live: the 'armed background drift check' log line on a declaring boot", + "evidence": "the test run output + the armed-timer log line" + } + ], + "negative": [ + "a drifted boot under the 'fail' default that comes up serving anyway is the highest-consequence FAIL of this item — the gate's whole purpose", + "an all-clear info line emitted while the metadata read was degraded is the #6504 conflation — FAIL", + "an abort message naming the wrong datasource/object, or omitting the per-column diff, is a FAIL against renderDiffMessage", + "a background drift check that throws or kills the process is a FAIL (drift past boot is observational by design)", + "the plugin must NO-OP silently (debug log only) when the external-datasource service is absent — an abort or error on a federation-free boot is a FAIL" + ], + "traps": [ + "stale-dist", + "absence-inference" + ], + "automated": { + "kind": "unit", + "ref": "packages/runtime/src/external-validation-plugin.test.ts (fail-default / warn / ignore / default-when-unreadable + drift-event emission) + packages/runtime/src/list-diagnosed-consumer-sweep.test.ts:334 (#6504 withheld all-clear) — the LIVE boot-abort arm is not pinned; an induced-drift boot is required for it" + }, + "source": [ + "packages/runtime/src/external-validation-plugin.ts:147-227 (Gate 2 kernel:ready validation; :188 runValidation; :224-226 the fail throw), :325-335 (resolveOnMismatch — `?? 'fail'` at :331, catch → 'fail' at :333), :78-122 (announceAllClear #6504; :113-121 the INCOMPLETE-sweep warn), :239-317 (background drift checks + external.schema.drift events)", + "packages/spec/src/shared/external-errors.ts:155-183 (renderDiffMessage + ExternalSchemaMismatchError — datasource, object, per-column diffs)", + "packages/cli/src/commands/serve.ts:2966-2993 (ADR-0015 federation block — ExternalDatasourceServicePlugin + createExternalValidationPlugin wired unconditionally, best-effort dynamic import)", + "packages/spec/src/data/datasource.zod.ts:301-318 (validation policy schema; checkOnBoot declared, default true — the declared≠enforced finding of clause 4; the liveness ledger note packages/spec/liveness/datasource.json claiming it 'gates the boot-time one' is contradicted by source)", + "examples/app-showcase/src/system/datasources/showcase-external.datasource.ts:40 (stock policy onMismatch:'warn' — deliberate, per its own comment)", + "docs/adr/0015-external-datasource-federation.md §5.2" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-20", + "change": "new item from the scoped scan-functionality (扫描功能) coverage sweep: the boot-time drift gate — a 'fail' DEFAULT that can refuse boot, the sweep's highest-consequence uncovered branch — had no checklist coverage. Fixture honesty declared up front: no stock drift fixture exists (stock showcase is in-sync AND deliberately 'warn'), so drift is induced in a scratch copy or the arms fall back to the unit pins. Also records clause 4's finding: checkOnBoot is declared in spec (default true) but read by no runtime code — the scan runs regardless", + "ref": "claude/new-session-0pv25p" } ] }, diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index da17c3220f..d339930e69 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -1169,6 +1169,91 @@ "ref": "#9299" } ] + }, + { + "id": "platform-core.interrupted-migration-boot-report", + "title": "Boot-time migration-journal scan: an interrupted run (started ∧ ¬done) is reported at kernel:ready with the exact `os migrate resume` prescription, boot PROCEEDS without resuming, and the two quiet-degradation arms (no journal object → silent; scan THROWS → 'could not check' warning) stay distinguishable", + "since": "v17", + "status": "active", + "revision": 1, + "priority": "P2", + "surface": "mixed", + "personas": ["operator (local shell)"], + "fixtures": { + "app": "showcase", + "requires": [ + "a scratch copy of the app whose objectstack.config.ts plugins array adds `new MigrationRecoveryPlugin()` (exported from @objectstack/runtime — packages/runtime/src/index.ts:58); PlatformObjectsPlugin (the journal object) is auto-registered by serve.ts §5c, but the RECOVERY plugin is not, so a stock config never runs the scan", + "an isolated persistent sqlite DB (own port, -d file:/tmp//journal.db) whose base schema was synced by one prior boot, so sys_migration_journal exists as a table to seed — the same scratch-DB class of fixture cli.migrate-plan-apply-json uses", + "a way to hand-insert rows into that sqlite file between boots (sqlite3 CLI or a small node script); the journal's API surface is read-only (apiMethods get/list), so REST cannot seed it" + ], + "knownGaps": [ + "NO stock boot path composes MigrationRecoveryPlugin: it is exported from @objectstack/runtime and instantiated only by its own unit test — serve.ts auto-registers PlatformObjectsPlugin but never this plugin, standalone-stack/default-host do not, the showcase config's plugins array does not, and buildDataMigrationPlugins (the migrate CLI's boot) does not either. A stock showcase boot therefore runs NO journal scan, and its silence over a seeded interrupted row proves nothing. If the runner judges the scan should be on by default (the plugin header and sys-migration-journal.object.ts:56-58 argue recovery must be 'discoverable with zero host wiring'), that is a composition finding to file — never a reason to tick or to skip the item." + ] + }, + "steps": [ + "create the scratch app per fixtures (config composes `new MigrationRecoveryPlugin()`); boot it once on its own port with -d file:/tmp//journal.db so schema sync creates the sys_migration_journal table; grep this first boot's log for migration-recovery output (expect none — clean-journal baseline); stop the server", + "hand-seed the crash-shaped state directly into the sqlite file: one row (run_id 'qa-interrupted-1', seq 0, kind 'run_started', plan_hash 'qa', detail '{\"planId\":\"qa_plan\"}') and one row (run_id 'qa-interrupted-1', seq 1, kind 'chunk_started', chunk_index 0) — started ∧ ¬done with an UNKNOWN-outcome chunk; read the table's real columns first (PRAGMA table_info) and respect the unique (run_id, seq) index; record the exact row count", + "boot again against the SAME DB; capture the FULL boot log, then GET /api/v1/health and /api/v1/ready once up", + "re-count sys_migration_journal rows after the boot and diff against the pre-boot count", + "cross-check the shared vocabulary: run `os migrate resume` (no --run — read-only list) against the same DB and compare its description of qa-interrupted-1 with the boot warning (describeInterruptedRun is one function shared by both, migration-recovery-plugin.ts:113-119)", + "conclude the run (insert run_id 'qa-interrupted-1', seq 2, kind 'run_done') and boot a third time: grep for migration-recovery output (expect none — and this silence is now meaningful because step 3 proved the scan active on this exact composition)", + "cover the two degradation arms via the pinned unit suite: pnpm vitest run packages/runtime/src/migration-recovery-plugin.test.ts — the 'quiet degradation' describe (absent journal object → zero warns) and 'reports a scan FAILURE rather than reading it as \"nothing found\"' (throwing find → the scan-failed warning)" + ], + "acceptance": [ + { + "clause": "the boot log warns per interrupted run, naming the run ('qa-interrupted-1'), the plan ('qa_plan'), the UNKNOWN-outcome chunk ('1 chunk(s) with UNKNOWN outcome (0) — started, never confirmed committed'), and the exact acting command — plus the summary 'interrupted migration run(s) found in sys_migration_journal. They are NOT resumed automatically — run \\'os migrate resume\\' to act on them.' Because the scratch boot registers no plan, the per-run line is the UNRESUMABLE variant ('No loaded plugin registers plan \\'qa_plan\\' … Load the package that owns it, then: os migrate resume --run qa-interrupted-1', describeInterruptedRun :147-152) — that variant still names the command; expecting the bare 'Resume with:' line here is a mis-read of the fixture, not a product FAIL", + "oracle": "log", + "verify": "grep the second boot's log for the per-run warning and the NOT-resumed summary (emitted at migration-recovery-plugin.ts:102-108)", + "evidence": "the boot-log excerpt containing both lines" + }, + { + "clause": "boot is discovery, the CLI is action: the server comes up healthy AFTER warning (health and ready answer 200), and the journal is byte-identical — post-boot row count equals the seeded count, no chunk progressed, no run concluded. Booting never writes to the journal; ANY write (an auto-resume) is the FAIL the plugin's design ruling exists to prevent (migration-recovery-plugin.ts:27-47)", + "oracle": "api", + "verify": "curl /api/v1/health and /api/v1/ready → 200 after the warning appears; sqlite count of sys_migration_journal rows before vs after the boot diffs empty", + "evidence": "both probe statuses + the two row counts" + }, + { + "clause": "on a composition where the scan is PROVEN active (clause 1, same config, same DB), a journal with nothing to find boots in silence: the first boot (empty journal) and the third boot (run concluded by run_done) both emit zero migration-recovery lines — findInterruptedRuns treats run_done as concluded (migration-journal.ts:373) and the empty result returns before any warn (:100). Absence of a warning is only meaningful once the arm is proven — this clause may not be scored on a stock boot, where silence means the plugin was never composed", + "oracle": "log", + "verify": "grep boots 1 and 3 for 'migration' / 'interrupted' recovery output; both empty, while boot 2 (between them, same composition) warned", + "evidence": "the three grep results side by side" + }, + { + "clause": "degradation arm 1 — a kernel with NO sys_migration_journal object skips the scan in SILENCE by design (the :85 early return): a kernel that never composed platform-objects has no interrupted runs to find, and warning there would train operators to ignore the one output that matters. Staged via the pinned unit test, not a live boot: serve.ts:2085-2098 auto-registers PlatformObjectsPlugin into every served kernel, so no honest served boot lacks the journal", + "oracle": "test", + "verify": "pnpm vitest run packages/runtime/src/migration-recovery-plugin.test.ts — 'skips silently when sys_migration_journal is not registered' passes (asserts zero warns)", + "evidence": "the vitest output naming the passing test" + }, + { + "clause": "degradation arm 2 — a journal read that THROWS is reported, not swallowed: the warning 'Migration journal scan failed; interrupted migrations (if any) were NOT detected this boot: ' (:93) — because 'I could not check' and 'there is nothing to find' are different answers, and conflating them would let a broken journal masquerade as a clean one", + "oracle": "test", + "verify": "same vitest run — 'reports a scan FAILURE rather than reading it as \"nothing found\"' passes (asserts the warn contains 'scan failed' and 'NOT detected')", + "evidence": "the vitest output naming the passing test" + } + ], + "negative": [ + "any journal write caused by booting — a new chunk_started/chunk_done row, the seeded run progressing or concluding — is an auto-resume, THE fail the boot-discovers/CLI-acts split exists for", + "a warning emitted on a journal-less lean kernel, or silence on a throwing scan, conflates the two degradation arms — 'silent by design' and 'could not check' must never trade places", + "silence on a STOCK showcase boot over a seeded interrupted row is not a pass of anything: stock composition never runs the scan (see knownGaps) — scoring that silence as 'clean journal' is the absence-inference trap applied to a plugin that was never loaded" + ], + "traps": ["absence-inference", "stale-dist"], + "automated": { "kind": "unit", "ref": "packages/runtime/src/migration-recovery-plugin.test.ts" }, + "source": [ + "packages/runtime/src/migration-recovery-plugin.ts — kernel:ready hook :75; silent no-engine return :78-83; silent absent-journal return :85; scan-failure warning :90-98 (string at :93); per-run warns + NOT-resumed summary :100-108; describeInterruptedRun :120-154 (unresumable variant :147-152); the boot-discovers/CLI-acts ruling in the header :27-47", + "packages/core/src/utils/migration-journal.ts — findInterruptedRuns :363-402 (started ∧ ¬done; run_done concludes :373; a run_failed run fully compensated is settled :380)", + "packages/platform-objects/src/system/sys-migration-journal.object.ts (row contract, (run_id, seq) unique index, apiMethods get/list — registered by PlatformObjectsPlugin)", + "packages/cli/src/commands/serve.ts:2073-2098 (PlatformObjectsPlugin auto-registered into every served kernel — which is why the absent-journal arm cannot be staged on a served boot; MigrationRecoveryPlugin itself is auto-registered NOWHERE)", + "packages/runtime/CHANGELOG.md 17.0.0, commit 071d0dc — 'boot reconciliation and os migrate resume for the migration journal' (ADR-0119 D2, #4617 deliverable 3)", + "cli.migrate-plan-apply-json (the CLI side of the same journal — this item is the boot-side DETECTION that item's `os migrate resume --json` step presumes)" + ], + "history": [ + { + "revision": 1, + "date": "2026-08-20", + "change": "new — authored in the scoped scan-functionality coverage sweep: the boot-side journal scan (ADR-0119 D2 deliverable 3, landed v17) had no item; existing coverage touched only the CLI half (cli.migrate-plan-apply-json's resume --json exit-code step). Grounding found the load-bearing fixture fact the item now encodes: MigrationRecoveryPlugin is exported from @objectstack/runtime but composed by NO stock boot path (not serve.ts, not standalone-stack, not the showcase config, not buildDataMigrationPlugins) — only its unit test instantiates it — so the item boots a scratch config that composes it explicitly, and treats stock-boot silence as the absence-inference trap, not evidence", + "ref": "claude/new-session-0pv25p" + } + ] } ] } diff --git a/docs/qa/platform-checklist/coverage.json b/docs/qa/platform-checklist/coverage.json index 56dbaf47e2..e7a8678e53 100644 --- a/docs/qa/platform-checklist/coverage.json +++ b/docs/qa/platform-checklist/coverage.json @@ -46,7 +46,9 @@ }, "datasource": { "items": [ - "integration-system.external-datasource-federated-read" + "integration-system.external-datasource-federated-read", + "integration-system.external-schema-introspection", + "integration-system.external-schema-drift-gate" ] }, "doc": { @@ -64,7 +66,8 @@ "records-forms.field-type-matrix", "records-forms.conditional-rules-header", "records-forms.cascading-options", - "records-forms.lookup-picker-create-new" + "records-forms.lookup-picker-create-new", + "attachments-storage.field-accept-maxsize-server-enforced" ] }, "flow": { @@ -80,7 +83,8 @@ }, "hook": { "items": [ - "records-forms.object-hook-lifecycle" + "records-forms.object-hook-lifecycle", + "cli.hook-body-extraction-gates" ] }, "job": {