Skip to content

QA run · tier2a:api-cli-build (23/23) · e4e5c6e3 · 2026-08-17 · 15 PASS / 6 PARTIAL / 1 FAIL #9417

Description

@claude

Round Tier-2A of the platform-checklist regression at #9296 — the 23 unpinned P0/P1 items on the api / cli / build surfaces. No item in this lane carries an automated.ref, so every clause below was driven by hand against a live server, a live CLI invocation, or the spec's own built contract face.

23 / 23 items reached a verdict. No handoff list — nothing was left unreached.


Environment fingerprint

framework sha (subject pin)e4e5c6e3c608b1b807c83a0d5b734f213eb1a1dd (HEAD never moved)
.objectui-sha pin665661ab093263f39f2e660a295ea615dbcee35a
console dist stamp665661ab093263f39f2e660a295ea615dbcee35a — equal to the pin
appexamples/app-showcase, seeded (--seed-admin, admin@objectos.ai)
ports used3131–3133, 3141–3149 (band 3130–3159), ≤3 concurrent
databasesfile:/tmp/qa-wave/t2a-*.db + per-probe scratch DBs, rm -f before each first boot
spec (checklist)re-synced mid-round from origin/main (git checkout origin/main -- docs/qa/platform-checklist/, check-platform-checklist: OK — 15 areas, 190 items). Never committed.
runnersession_01Ai9gUdihXjg7FumzsBNSSs (Tier-2A)
repo tree at closeclean apart from the intentional, uncommitted docs/qa/platform-checklist/ sync

Spec drift handled mid-round.origin/main moved after the QA tree was synced (#9385, #9366). The sync was taken at a clean point between items, before anything in this lane was scored. Only one owned item changed: cli.qa-suite-execution rev 2 → rev 3. It was scored against rev 3, i.e. against #9385's current behaviour (record action types resolve the Data Protocol mount from /discovery), not the superseded rev-2 expectation. Every other owned item's revision was re-resolved after the sync and was unchanged.


Scope — 23 items, revision pinned per item

itemrevpriosurfaceverdict
access-security.sharing-rules-widen1P1apipartial
access-security.record-share-grant-revoke2P1apipartial
access-security.no-active-org-session-semantics1P0apiblocked
ai.agent-tool-skill-metadata-roundtrip2P1apipartial
ai.mcp-run-action-exposure-gate2P1apipass
api-backend.batch-transactional-discovery2P1apipass
api-backend.formula-gates2P1buildpass
api-backend.enforce-or-remove-authoring-gates2P1buildpartial
api-backend.error-envelope-ledger1P1apipass
api-backend.filter-comparand-conformance1P1apipass
api-backend.retired-def-refusal1P1buildpass
cli.dev-boot-contract2P0clipass
cli.build-own-contract2P1buildpass
cli.qa-suite-execution3P1clipass
cli.migrate-meta-codemod1P1cliFAIL
dashboards.saved-report-ownership2P1apipartial
identity-auth.self-service-password-reset1P1apipass
integration-system.connector-degraded-recovery2P1apipass
integration-system.external-datasource-federated-read1P1apipass
platform-core.seed-integrity1P0apipass
platform-core.docs-audience-gate1P1apipartial
records-forms.validation-rule-type-matrix1P1apipass
search.rls-both-personas2P1apipass

Derived totals — 15 PASS / 6 PARTIAL / 1 FAIL / 1 BLOCKED. Item verdicts are derived from the clause table below, never hand-assigned.

Both P0 items in this lane are accounted for: cli.dev-boot-contractpassed 7/7, platform-core.seed-integritypassed 5/5. No P0 failure. No authentication or authorization hole was found — every access-control clause in this lane proved both sides of its gate (entitled presence and server-side refusal for the unentitled persona, by forged request where the door allowed one).


Per-clause verdicts

Oracle evidence is text only, as required. reads observed.

access-security.sharing-rules-widen · rev 1 · partial

Live audience: share_new_inquiries_with_field_ops (showcase_inquiry, OWD private, recipient unit_and_subordinates: bu_field_ops). Member D was placed in bu_west_coast — a descendant of bu_field_ops, so the subtree walk is exercised, not just flat membership; outsider E in bu_hq_finance.

#clauseverdictoracle evidence
1audience member reads the MATCHING record though OWD is privatepassD: GET /data/showcase_inquiry/<Priya> → 200; D's list → total 1 ["Priya Raman"]. The share materialised on the BU-membership insert itself (share created_at 23:08:46.921 vs member insert .902); the explicit POST /sharing/rules/<name>/evaluate then reported {matchedRecords:1, expandedUsers:1, grantsCreated:0} — already present.
2widening is scoped — both sidespassFour reads: D→matching 200, D→non-matching (Lena, status closed) 404; E→matching 404, E→non-matching 404 (pre-share). Neither list leaks the other's row.
3widening is materialised + explain names SHARING as the deciding grantpasssys_record_share row shr_cb01e713…{source:'rule', source_id:'srule_c4f6be61-…', reason:'rule:share_new_inquiries_with_field_ops'} — the rule row id, not the name. POST /security/explain {object,operation:'read',userId:D,recordId:Priya}record:{visible:true, decidedBy:"sharing"}, layer sharing verdict widens with rules:[{kind:'sharing_rule', name:'srule_c4f6be61…', effect:'admits'}] while owd_baseline is narrows. Same call for Lena → record:{visible:false}, effect:'neutral'.
4manual one-off share grants exactly the targetpassPOST /data/showcase_inquiry/<Lena>/shares {recipientType:'user',recipientId:E} → 201; E reads Lena 200, third user D reads Lena 404.
5§A13 — an admin edit to a materialised share survives restartblocked(fixture)The edit is unreachable: sys_record_share is API-read-only — PATCH/DELETE405 OBJECT_API_METHOD_NOT_ALLOWED … allowed:["get","list","aggregate","search","export"], PUT → 405, and the record-shares routes carry no update verb. The #2909 substance was nonetheless proved independently: after a full server restart on the same DB, both shares (rule-materialised and manual) re-read byte-identical and every access decision was unchanged (D: match 200 / non-match 404 / list 1; E: match 404 / non-match 200 / list 1). The seed does not clobber shares.

access-security.record-share-grant-revoke · rev 2 · partial

Personas A/B/C = three fresh sign-ups; probe showcase_private_note (OWD private).

#clauseverdictoracle evidence
1PRE-grant isolationpassB GET /data/showcase_private_note/<A id>404 RECORD_NOT_FOUND; B's list total 0.
2manual grant lands as a sys_record_share rowpassPOST …/shares201{id:"shr_ba4e283e…", recipient_type:"user", recipient_id:<B>, access_level:"read", source:"manual", source_id:null, granted_by:<A>}; confirmed by a system-context sys_record_share read.
3grant widens B both ways and stays scopedpassB by-id 200 and B list total 1 ["95QFJ…"]; C by-id 404, C list total 0.
4shares list is management-gated (ADR-0111 D5)passA GET …/shares → 200 with the grant. B POST …/shares403PERMISSION_DENIED: managing shares on showcase_private_note/95QFJ… requires record ownership or Modify All Data (ADR-0111 D1). Reading confers no re-share authority.
5revoke retracts accesspassDELETE …/shares/<shareId>204; B's next by-id → 404; system-context sys_record_share read → [].
6revoke is record-scoped (ADR-0111 D4)passDELETE /data/showcase_private_note/<other id>/shares/<shareId>404share shr_ba4e283e… not found on showcase_private_note/1wEywE3…; the share survived (A's re-read still lists it). Run before the real revoke, so survival is meaningful.
7rule evaluate reconciles share_red_projects_with_execsblocked(fixture)POST /sharing/rules/share_red_projects_with_execs/evaluate422SHARING_NOT_ENABLED: 'showcase_project' is not under record-sharing enforcement (public sharing model or no 'owner_id' field); a share row on it would never be consulted. See the fixture-gap section — the rule is inert on stock. The evaluate contract itself was proved on the enforceable sibling rule (see sharing-rules-widen clause 3): {ruleId, matchedRecords, expandedUsers, grantsCreated, grantsUpdated, grantsRevoked} and source_id === sys_sharing_rule.id.

access-security.no-active-org-session-semantics · rev 1 · blocked(fixture) · P0

All 7 clauses blocked(fixture). Not re-derived — two prior independent rounds already established it; this round only re-confirmed the premise from the boot banner: Tenancy: single, sys_organization count 0 at first boot (the #8686 diagnostic quotes "organizationCount":0). ADR-0095 Layer 0 is inert, every clause vacuous; OS_TENANCY_POSTURE=isolated is correctly refused because @objectstack/organizations is not declared by the host app.

Ledger action required — the item carries no blocked marker, so checklist-select.mjs presents it as runnable (--include-blocked changes nothing). This is the fourth round to reach the same dead end. See fixture gaps.

ai.agent-tool-skill-metadata-roundtrip · rev 2 · partial

#clauseverdictoracle evidence
1valid trio parses; every tombstone rejected LOUDLY with the retired key namedpassValid trio parses with documented defaults (surface:"ask", active:true on agent and skill). All 10 tombstone/guidance probes rejected, each naming its key and its prescription: tool.permissions ("promised a capability gate on tool invocation that nothing ever enforced"), tool.active, tool.category, tool.builtIn, tool.requiresConfirmation (#3896 / #3715 + ADR-0033 §2); agent.tools ("use skills … (ADR-0064)"), agent.knowledge; skill.triggerPhrases ("phrases were never matched … activation is triggerConditions"); guidance-map probes skill.permissions and skill.trigger. Zero silent strips.
2registry serves all three kinds under SINGULAR namespassGET /meta and GET /meta/types both list 34 kinds including agent, tool, skill; agents / tools / skills all absent.
3authored items round-trip field-identical with schema defaults appliedblocked(fixture)No stock example app ships AI seeds (the item's own knownGap), and this run writes nothing into the repo, so the build-authored round-trip could not be exercised. Runtime-authored substitute: PUT …?mode=draft → publish → GET /meta/skill/qa_probe_skill echoes every authored value plus _diagnostics:{valid:true} — but not the documented schema defaults surface:'ask' / active:true, which the contract face does apply. Recorded as the thing to check when the fixture lands.
4runtime write door matches the declared registry posture — both sidespassPUT /meta/skill/qa_probe_skill?mode=draft200{state:"draft", seq:1}; PUT /meta/tool/…?mode=draft200seq:2; PUT /meta/agent/qa_probe_agent?mode=draft403[not_creatable] Metadata type 'agent' is code-only: the metadata-type registry declares allowRuntimeCreate=false and allowOrgOverride=false … Declare it in source (**/*.agent.ts) and redeploy. Recorded as CORRECT, not a bug. (Draft read-back is ?state=draft; ?mode=draft is the write-side param and 404s on read — a wording nit, not a defect.)
5anonymous-deny on the AI meta surfacepassUnauthenticated GET /meta/agent, /meta/tool, /meta/skill401UNAUTHENTICATED on all three.
6tool metadata is a read-only projectionpassPOST /ai/tools/qa_probe_tool/execute501{code:"NOT_IMPLEMENTED", message:"Provided by @objectstack/service-ai in ObjectStack Cloud/Enterprise — no implementation ships in the open framework"}. Authored ⇏ callable.

ai.mcp-run-action-exposure-gate · rev 2 · pass (5/5)

Over MCP Streamable HTTP with a minted osk_ key (POST /api/v1/keys → 201).

#clauseverdictoracle evidence
1undeclared action rejected fail-closed with the exact prescription; zero side effectspassrun_action{actionName:'showcase_mark_done', objectName:'showcase_task', recordId:<a NOT-done task>}isError:true, text exactly Action 'showcase_mark_done' on 'showcase_task' is not exposed to AI — the app author must opt it in with \ai: { exposed: true, description: … }`. REST re-read: status todo, done false, **updated_atunchanged**. Refusal reproduced a second time,isError:true` both times.
2list_actions returns only ai-exposed + permittedpassGlobal call and objectName:'showcase_task' call both return exactly {actions:[showcase_portfolio_snapshot], totalCount:1}. showcase_mark_done absent from both. The two doors agree.
3exposed action executes and its counts reconcilepassrun_action{showcase_portfolio_snapshot}{ok:true, scope:"global", accounts:19, projects:9, invoices:12}; direct REST totals → 19 / 9 / 12.
4trusted elevation is AUDIBLEpassServer log: [action-audit] MCP run_action 'showcase_portfolio_snapshot' on 'global' — body executes TRUSTED (system-elevated context, RLS/FLS-bypassing) for user 'SYkUFihutw3CQtflO98VqdK7NOWSaQqF'.
5system objects refused wholesalepassrun_action{objectName:'sys_user'}isError:true, Object "sys_user" is a system object and its actions are not exposed via MCP. No side effect.

api-backend.batch-transactional-discovery · rev 2 · pass (5/5)

Three checklist-accuracy corrections came out of this item; none is a product defect.

#clauseverdictoracle evidence
1discovery bit true exactly when /batch is mounted and tx-capablepassGET /discoverycapabilities.transactionalBatch = {enabled:true, description:"Atomic cross-object batch endpoint (POST {basePath}/batch) …"}, and the atomic cross-object batch (create showcase_private_note + create showcase_task in one call) succeeded end-to-end — both rows verified present afterwards. Capability read and live behaviour agree.
2atomic:false on the cross-object door → 400 BATCH_NOT_ATOMICpassPOST /batch {operations:[…], atomic:false}400{error:"Cross-object batch is always atomic; use POST /data/:object/batch for non-atomic per-object batches", code:"BATCH_NOT_ATOMIC"} — the message points at the per-object door exactly as specified.
3a failing member rolls the whole atomic batch back with the #4793 per-row codespassRollback proved on the cross-object door: a validation-time failure (showcase_invoice missing required fields) → 400, and a write-time failure (update of a nonexistent id) → 404 — in both cases neither flanking showcase_private_note create persisted. The #4793 per-row codes are emitted by the per-object door: POST /data/showcase_private_note/batch {options:{atomic:true}}[{ROLLED_BACK},{VALIDATION_FAILED},{NOT_ATTEMPTED}] in exactly those positions, and the first row did not persist. Both conjuncts proved; the clause attaches them to one door.
4oversize batch refused up front, creates nothingpass201 operations → 400{code:"BATCH_TOO_LARGE", count:201, max:200}; a system-context count of the probe-titled rows is 0.
5per-object door defaults to NON-atomic per-row outcomes (ADR-0119 D4)passDefault (atomic unset): [{ok},{VALIDATION_FAILED},{NOT_ATTEMPTED}], succeeded 1 / failed 2, row 1 stayed written — no rollback, so atomic does default false. The "neither blocks the other" reading is the separate continueOnError dial (default also false, #7539): with options:{continueOnError:true}[{ok},{VALIDATION_FAILED},{ok}], succeeded 2 / failed 1, both valid rows persisted.

api-backend.formula-gates · rev 2 · pass (4/4)

#clauseverdictoracle evidence
1the three runtime shapes evaluate against the BUILT packagepassHarness imported /home/user/objectstack-qa-wave/packages/formula/dist/index.jsresolved path captured, dist/, never src/. record.due_date == today(){ok:true,value:true} for today's ISO date and {ok:true,value:false} for 2020-01-01; record.flag ? record.x : null42 / null; floor(3.7)3; ceil(3.2)4.
2date arithmetic is a build-time ERRORpassScratch config with Field.formula({expression:'record.end_date - record.start_date + 1'})os buildexit 1.
3the build error is LOCATED and actionablepass• object 'qa_probe' · field 'qa_duration_days' expression: date arithmetic \google.protobuf.Duration + int` — record.start_date is a date, and CEL can't do arithmetic on dates: this faults at runtime, so the field silently evaluates to null. Use `daysBetween(a, b)` … `daysFromNow(n)` / `addDays(d, n)` / `addMonths(d, n)`+source: `record.end_date - record.start_date + 1`+rule: expression-invalid at object 'qa_probe' · field 'qa_duration_days' expression`.
4the gate does not over-firepassos build on the untouched showcase → exit 0 (✓ Build complete (2082ms), artifact 690.1 KB), despite its legitimate due_date == today() comparisons.

api-backend.enforce-or-remove-authoring-gates · rev 2 · partial

#clauseverdictoracle evidence
1each tombstoned key REJECTED at parse/build, never silently strippedpassQuerySchema: cursor, joins, distinct, windowFunctions — all four rejected (control: a valid query parses). EnhancedApiErrorSchema.fieldErrors rejected. ObjectSchema display annotations helpText/displayHint/uiNote/color — all four rejected. Retired filter operators enforced at the live driver door: GET …?$filter={"name":{"$regex":"North"}}400.
2every rejection carries LOCATED guidance naming the replacementpasscursor"no driver ever implemented keyset pagination … QueryBuilder.cursor() was removed with it"; joins"Related records are read through expand"; distinct → names the mis-wired total/hasMore degradation; windowFunctions → *"a SQL-driver capability behind SqlDriver.findWithWindowFunctions(…)"*; fieldErrors→ *"renamed tofieldsin spec 17 (ADR-0114 D4, #3977) — the array is unchanged, only the property name"* (andfieldsparses clean);$regex→ *"is RETIRED … Write$icontainsinstead"* with the registry'swhy` quoted verbatim. No bare "unknown key".
3a key documented as silently-stripped-BY-DESIGN still parses cleanblocked(fixture)No such key was locatable across the sampled surfaces. The shapes that remain open are documented .passthrough()dashboard.zod.ts:252"declared query keys + open renderer extras", view.zod.ts Gantt config, bulk-action.zod.ts params/options — i.e. open-by-design that keeps and forwards unknown keys, the opposite of a strip. Every display annotation probed is now a loud strict rejection (#4001/#1535, ADR-0085 closed the former detail:{}.passthrough()). Scoring a keep-by-design as a strip-by-design would be ticking on a label.
4sample covers ≥3 distinct registries/surfacespassFour: query schema (QuerySchema, #4286) · error envelope (EnhancedApiErrorSchema, ADR-0114 D4) · retired filter operators (RETIRED_FILTER_OPERATORS = {$regex→$icontains, $options→$icontains}, data-only by design, enforced at the driver refusal site) · object display-annotation strictness (ObjectSchema, #4001).

api-backend.error-envelope-ledger · rev 1 · pass (6/6)

Seven samples, one per family; membership checked against StandardErrorCode (53) ∪ ERROR_CODE_LEDGER (22) ∪ REGISTERED_ERROR_CODES (237), read from the built spec.

#clauseverdictoracle evidence
1every sampled code is LEDGEREDpassVALIDATION_FAILED (400) · UNAUTHENTICATED (401) · PERMISSION_DENIED (403) · OBJECT_NOT_FOUND (404) · RECORD_NOT_FOUND (404) · BATCH_NOT_ATOMIC (400) · UNSUPPORTED_QUERY_PARAM (400) — 7/7 ledgered, none unregistered.
2validation sample carries fields[] with FIELD-level codespassPOST /data/showcase_invoice {}{error:"Invoice Number is required; Account is required", code:"VALIDATION_FAILED", fields:[{field:"name",code:"required",message:"Invoice Number is required",label:"Invoice Number"},{field:"account",code:"required",…}]}; requiredFieldErrorCode (28 members, lowercase snake).
3retired fieldErrors never appearspassKey-absence assertion across all 7 captured bodies → absent everywhere.
4HTTP status matches the declared mappingpassStatus-vs-code table matches HttpStatusErrorCodeMap tier for tier (400 validation/filter/param, 401, 403, 404 ×2). No 5xx on any caller-fixable input.
5the two 404 flavours are distinguishablepassObject: {error:"Object 'not_a_real_object' is not registered", code:"OBJECT_NOT_FOUND"} vs record: {error:"Record nonexistent-id-0000 not found in showcase_task", code:"RECORD_NOT_FOUND"} — different codes and different messages.
6one sample per family variantpassvalidation · auth · authorization · not-found (object) · not-found (record) · batch registered-extension code · query-rejection = 7 families, each with its own verdict.

api-backend.filter-comparand-conformance · rev 1 · pass (5/5)

ACCEPTED_FILTER_COMPARAND_TYPES read live from the built spec = ["string","number","bigint","boolean","null","Date"]; FILTER_COMPARAND_BIGINT_EXACT_LIMIT = 9007199254740992.

#clauseverdictoracle evidence
1every member accepted, answer set reconciles against a directly-computed expectationpassREST door (JSON-expressible members), each 200 with the answer set matching a directly-computed count: string total 1=1 · number {tax_rate:8} 6=6 · boolean {done:true} 2=2 · null {paid_on:null} 7=7 · Date-as-ISO {issued_on:{$gte:'2026-01-01'}} 10=10. bigint and Date are not JSON-expressible, so they were exercised at the shared contract face: 7n → accepted, narrowed to number 7; new Date(…) → accepted, narrowed to ISO 8601. Six for six.
2a non-accepted comparand is REFUSED, located and prescriptive — never droppedpassPlain object at $eq → 400 Filter comparand at where.tax_rate.$eq is a plain object ({"a":1}), which no driver can compare. A comparison value must be a string, number, bigint, boolean, null or Date. Refusing rather than guessing… — path named, accepted set quoted. Also refused: array in scalar and implicit positions, undefined ("cannot be told apart from an omitted key, yet the two mean OPPOSITE things"), function, Symbol. The dangerous direction was probed explicitly: every bad predicate returned 400; none returned 200 over an unfiltered table.
3bigint exact-integer limit enforced at the boundarypass2n**53n → accepted, 9007199254740992 (number). 2n**53n+1n and -(2n**53n)-1nrefused: "the bigint 9007199254740993n, whose magnitude exceeds 2^53 — it has no exact JS-number form, so a comparison built on it would silently answer the wrong rows". No rounding.
4dotted heads CLASSIFIED and handled per contractpassclassifyDottedFilterHead: lookup/master_detailrelation, text/summaryscalar, formulavirtual, jsonnull, multiple:truenull. All three reachable classes refused over REST with class-specific text: relation head owner.name"follows the relationship … a filter reaches only columns of 'showcase_invoice' itself"; scalar head name.sub"a 'text' field … stores a single scalar value — there is nothing beneath it"; virtual head budget_remaining.x (a real formula field) → "a dotted path whose head 'budget_remaining' is a virtual 'formula' field … computed on re[ad]" — so the virtual class was NOT blocked(fixture) here. A scalar head is never traversed as a relation.
5the refusal is CONTRACT-level, not a transport quirkpassThe same bad comparand through three doors — GET ?$filter=, POST /query {filters}, POST /query {where} — all 400 INVALID_FILTER with byte-identical message text, and the same refusal at the spec's normalizeFilterComparandTypes face.

api-backend.retired-def-refusal · rev 1 · pass (4/4)

Registry: retired-defs holds 54 entries — 53 at prefix 17, 1 at prefix 18.

#clauseverdictoracle evidence
1every sampled retired definition REFUSED at os buildpassFour sampled defs declared as stack collections on a scratch app: externalDataSources, widgetManifests, messageQueueConfigs, apiKeysall non-zero exit (2, the oclif error exit for a load-time defineStack throw), each Unrecognized key(s) on this stack definition: \`. Until #8687 closed this surface (the outermost #4001 door), an unknown top-level stack key parsed green and its value was silently dropped…`. No green build with a family silently absent.
2refusal distinguishable from generic where a prescription WAS authoredpassRoute recorded per def from each entry file: 17.data__ExternalDataSource and 17.system__MessageQueueConfigRoute 3 ("no tombstone, no D2 conversion — this table plus the D3 entry are the declaration"); 18.identity__ApiKey#8715"disposition B: delete"; 17.ui__WidgetManifest → bare table entry, no prescription authored. All four are prescription-less by design, so per the item's own knownGap the generic refusal is the CORRECT answer — and that is what was observed: the four refusals are identical in shape to the control (totallyInventedThing), differing only in the echoed key name.
3runtime metadata-write door refuses the same typespassPUT /api/v1/meta/<type>/qa_retired_probe?mode=draft for external_data_source, widget_manifest, message_queue_config, api_keyand the control totally_invented_type400 on all five: [invalid_request] '<type>' is not a metadata type. The platform declares no such type, and since #8586 retired 'additionalTypes' a plugin cannot declare one…. No two-door disagreement. (Registered types: 34, none of the sampled defs among them.)
4sample spans both version prefixespass17.data__ExternalDataSource, 17.ui__WidgetManifest, 17.system__MessageQueueConfig (prefix 17) + 18.identity__ApiKey (prefix 18), across four domains: data/, ui/, system/, identity/.

cli.dev-boot-contract · rev 2 · pass (7/7) · P0

#clauseverdictoracle evidence
1boot reaches health; console served with --uipass/api/v1/health → 200; /api/v1/ready → 200 {"status":"ready","state":"running"}; /_console/ → 200 serving the console document (<!doctype html> … <base href="/_console/">).
2--seed-admin is idempotent — a later --admin-password does NOT overwritepassReboot on the SAME DB with --admin-password changed99: sign-in with the ORIGINAL admin123200; sign-in with changed99401. Zero-user-DB-only, confirmed.
3DB selection honours the one shared resolution matrix (#6469)passTier for tier, from the printed Database: key-value: explicit -dfile:/tmp/qa-wave/t2a-a.db; env OS_DATABASE_URLfile:/tmp/qa-wave/env-chosen.db; -d :memory::memory: (no file default imposed); legacy compat-read → see below; nothing chosen → file:<project>/.objectstack/data/objectstack.db — the persistent project-anchored unified default, never :memory:, and the file was verified on disk (1.7 MB). Legacy tier: with only dev.db present, the boot read …/.objectstack/data/dev.db and printed ⚠ Reading legacy database file … — the unified default is now …/objectstack.db (#6469); migrate with: mv "…/dev.db" "…/objectstack.db" … or pin it explicitly via OS_DATABASE_URL=… — and did not create a fresh empty objectstack.db beside it. Both halves of the negative satisfied. (The "config-declared default datasource" tier is not supplied by the stock fixture — the showcase config declares no default datasource; no variant existed to run.)
4--fresh isolates OS_HOME state in an auto-deleted tempdirpass🧪 Fresh OS_HOME: /tmp/objectstack-dev-z6lz37, its DB file:/tmp/objectstack-dev-z6lz37/data/objectstack.db — the SAME unified filename under the ephemeral home. After exit the directory is gone. The app-declared cwd-relative examples/app-showcase/.objectstack/data/showcase_external.db survived with an unchanged mtime — annotated as the documented #5594 carve-out, not filed as a bug.
5a busy requested port auto-shifts AND is reportedpassInstance B on a busy 3131 printed ↪ server bound to port 3133 (requested 3131) — and every printed URL (API, Console, MCP endpoint, MCP skill) used the ACTUAL bound port 3133, never the requested one.
6a stale artifact is called out at boot, and still bootspassWith dist/objectstack.json aged behind the sources: ⚠ dist/objectstack.json is OLDER than your sources — this boot serves the STALE build. / newest source: src/ui/views/contact.view.ts (2421d newer than the artifact) / fix: run \objectstack build` or start with `--compile`, or save a watched file to trigger a rebuild + restart.— and/api/v1/health` → 200. Warn, never gate.
7no config and no artifact exits 1 with the remedypassFrom an empty scratch dir: stdout ✗ Config file not found in /tmp/qa-wave/scratch-empty, stderrRun in a directory with objectstack.config.ts, pass --artifact <path|url>, or run from the monorepo root., real echo $? = 1. No hang, no silent 0.

cli.build-own-contract · rev 2 · pass (6/6)

On a scratch app outside the repo (/tmp/qa-wave/app), so no deliberate breakage touched a shared fixture.

#clauseverdictoracle evidence
1success: exit 0, artifact written, summary + --json payloadpassHuman path: exit 0, ✓ Build complete, Artifact: dist/objectstack.json (1.5 KB), stats printed. --json: {"success":true,"output":"…/dist/objectstack.json","size":1531,"warnings":[],"conversions":[],"stats":{objects:1,fields:3,…},"duration":76} — every declared key present. Artifact parses.
2a schema violation fails with a LOCATED error naming the failing pathpassBad field type → ✗ objects.0.fields.name.type: Invalid field type 'not_a_real_type'. Valid types: text, textarea, email, … — the path is named, never a bare "validation failed". Exit 1 on the --json path; 2 on the human path, which is the oclif this.error() exit that clause 5 explicitly blesses.
3author-time rule failures exit 1; ALL failing rules report at oncepassTwo objects each missing sharingModel✗ Author-time rules failed (2 issues) listing both, each with where/message/hint/rule: security-owd-unset/at objects[N].sharingModel. Exit 1. Not first-failure-only.
4--json failure/advisory shapes match os validate --jsonpassbuild --json{"success":false,"error":"author-time rules failed","issues":[{severity,rule,where,path,message,hint}…]}; validate --json{"valid":false,"errors":[{severity,rule,where,path,message,hint}…]} — identical per-issue keys. Success payloads carry warnings + conversions under the same names.
5#4873 exit-code honesty — a STATUS, never a computed valuepassEvery observed exit ∈ {0, 1, oclif 2 from a this.error() path}. Stability proved by repetition: success repeated → 0, 0; --json success → 0; schema break repeated → 2, 2 (and 1 on --json); rule failure repeated → 1, 1. Nothing varied between identical runs; no code tracked a duration or a finding count.
6advisories never flip the exitpassos build on the untouched showcase: ⚠ 26 author-time warning(s) — see above (e.g. rule: security-master-detail-ungranted at objects[6].fields.expense_report), the artifact was written (690.1 KB) and echo $? = 0. Both sides of the warn/fail line. (The specific #3786 undeclared-authoring-key advisory is no longer producible — #4001/#8687 turned undeclared keys into hard errors; the warn/fail property was proved with a live advisory class instead.) Alias: os compile on the same input behaves identically (exit 0, same artifact line).

cli.qa-suite-execution · rev 3 · pass (8/8)

Scored against the post-#9385 revision. Stock mount, so neither the crud.dataPrefix nor the api.apiPath case bites — as rev 3 states.

#clauseverdictoracle evidence
1the green path closespassFound 1 test suites. (N > 0, quoted as the clause demands), ✅ Scenario: health-liveness (48ms), ✅ Scenario: category-create-read-roundtrip (571ms), SUCCESS: All 2 scenarios passed., echo $? = 0.
2state threads through the run (capture → {{var}} → later step)passThe roundtrip passed AND was confirmed independently by server state: showcase_category held exactly 4 rows before, 4 after, with identical names — the teardown DELETE …/{{categoryId}} actually removed the created row. Dead interpolation would have 404'd on the literal path and left a 5th row.
3a false assertion fails the scenario and the runpass❌ Scenario: false-assertion-probe / Step Failed: GET health expecting degraded / Assertion failed: data.status expected degraded, got ok; exit 1.
4#7256 — a non-evaluable assertion FAILS, it does not passpassAssertion failed: data.nope cannot be evaluated by 'contains' — expected an array or a string at that path, got undefined. The path resolved to nothing — the field is absent from the result, or the path is misspelled. Use 'is_null' if asserting absence is what you meant.
5#6247 — load-site enforcementpassqa/a-malformed.test.json is not a valid Quality Protocol suite (TestSuiteSchema): / ✗ scenarios.0.steps: Invalid input: expected array, received undefined / Expected shape: {…} / Reference: content/docs/references/qa/testing.mdx. The two sibling suites still executed after it, it counted as a failed suite, exit 1, no TypeError inside runSuite.
6teardown runs after a failed step; a passing teardown does not rescue the verdictpassStaged create→capture→deliberate-fail→teardown-delete: scenario verdict stayed , and QA-T2A-TEARDOWN-PROBE is absent from the showcase_category listing afterwards.
7all 8 TestActionTypeSchema members carry their OWN verdictpasscreate_record ✅ · read_record ✅ · update_record ✅ · query_records ✅ · delete_record ✅ · api_call ✅ · wait ✅ · run_scriptUnsupported action type in HttpAdapter: run_script. Exactly one refusal — the honest verdict rev 3 predicts. The five record-shaped types executed (post-#7848).
8#7363 glob disciplinepassBare os test from examples/app-showcase found the shipped suite with no argument. os test 'qa/**/*.test.json' from the same dir → Found 1 test suites., SUCCESS, real 3.99s (not the pre-#7363 multi-minute walk). Zero-match glob → Found 0 test suites. / No test files found matching: qa/nothing-matches-*.test.json / Exiting 0 — an empty match is not a failure. Pass --fail-on-empty to make it one., exit 0 — the declared #7848 posture.

cli.migrate-meta-codemod · rev 1 · FAIL (4 pass, 2 fail)

#clauseverdictoracle evidence
1every planted retired spelling is REWRITTEN to its declared successorFAILui/ListView:striped (registry entry packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__striped.ts, D2 conversion view-list-passthrough-keys-removed) is left untouched: os migrate meta --from 16 exits 1 without rewriting anything. Reproduction rule below.
2the rewritten tree BUILDSFAILThere is no rewritten tree. os build after the codemod run produces the identical pre-migration refusal.
3idempotent — a second run changes nothing and exits 0passOn an already-migrated (retired-key-free) tree: two consecutive --json runs produced byte-identical output and exit 0; md5sum -c confirmed nothing was rewritten in place (the default is a read-only replay; in-place effects need --out or --stored --apply).
4a --from below the floor is REFUSED loudlypassOn a loadable tree: --from 3 → exit 1, ✗ Cannot migrate from protocol 3: the chain's support floor is 10 (ADR-0087 D3). Upgrade to protocol 10 by another path first, then re-run.; --json{"error":"unsupported_from_major","message":"…"}. Caveat recorded: on a source that carries a retired key the floor check is unreachable — the load aborts first (a consequence of clauses 1–2).
5semantic entries surfaced as ADVISORIES, distinguished from mechanical rewritespass--json on a clean tree separates them into two arrays: applied:[{conversionId:"field-required-notnull-explicit", surface:"object.fields.*.required / …", path:"objects[0].fields.name.storage.notNull"}] vs todos:[{id:"action-descriptor-is-async-retired", surface:"ActionDescriptor.isAsync …"}]. Nothing advisory is reported as "migrated".
6--json exits 0 on success (#4873)passExit 0 with the {from:16, to:17, runtime:"17.0.0", applied:[…], todos:[…]} payload.

Registry sizes recorded as the item asks: retired-defs54, retired-keys38, semantic104. Advertised majors, grepped from the retirement messages: os migrate meta --from 16 appears 132 times, --from 174 times.

dashboards.saved-report-ownership · rev 2 · partial

#clauseverdictoracle evidence
1cross-owner GET/DELETE/run all 404 REPORT_NOT_FOUNDblocked(fixture)The reports service is not installed on stock showcase: every /api/v1/reports* call by an authenticated caller answers 501 {"code":"NOT_IMPLEMENTED","message":"Reports service is not configured on this deployment"}. packages/plugins/plugin-reports exists in the repo but is absent from the showcase's 48-plugin boot list.
2the list never leaks; ownerId cannot be spoofedblocked(fixture)same 501
3anonymous access answers 401 UNAUTHENTICATEDpassAnonymous GET /api/v1/reports and GET /api/v1/reports/:id401{"error":"UNAUTHENTICATED","message":"Authentication is required to access this endpoint."}. Notably the auth gate runs before the service-availability check, so an anonymous caller learns nothing about whether the service exists — the correct anti-enumeration ordering.
4schedule routes are owner-gated too (#6683)blocked(fixture)same 501
5A's artifacts survive every denied attemptblocked(fixture)same 501

identity-auth.self-service-password-reset · rev 1 · pass (5/5)

#clauseverdictoracle evidence
1the request issues a real reset artifact carrying a tokenpassPOST /api/v1/auth/request-password-reset → 200. The artifact was captured as server truth rather than log text: the sys_email row for the target reads subject:"Reset your ObjectStack password", status:"sent", body containing http://localhost:3131/api/v1/auth/reset-password/qvO9EIo2noPw7MPB5p1FC3WU?callbackURL=… and link expires in 60 minutes. (Oracle substitution recorded: sys_email outranks the clause's log oracle.) Exactly one row — the unknown-email request produced none.
2the token completes the reset and rotates the credentialpassPOST /auth/reset-password {token,newPassword} → 200 {"status":true}; sign-in NEW → 200; sign-in OLD → 401INVALID_EMAIL_OR_PASSWORD.
3a consumed token cannot be reusedpassSecond reset with the same token → 400{"message":"Invalid token","code":"INVALID_TOKEN"}; the would-be third password does not sign in (401); the second password still does (200).
4an expired/tampered token is refused LOUDLY, credential unchangedpassGarbage token and a two-character-tampered token both → 400INVALID_TOKEN; the tampered password never signs in (401) and the current one still does (200). No silent success.
5request does not leak account existencepassKnown and unknown emails return an identical200 {"status":true,"message":"If this email exists in our system, check your email for the reset link"}. The server-side [Better Auth]: Reset Password: User not found stays in the log and never reaches the caller.

Route divergence confirmed live, as the item's steps predict: /api/v1/auth/request-password-reset serves (200) while /api/v1/auth/forget-password — the spelling in the spec's AuthEndpointPaths404s.

integration-system.connector-degraded-recovery · rev 2 · pass (5/5)

Upstream broken without mutating any repo source: the server was launched with node removed from the child PATH, so the connector's spawn('node', ['./scripts/mcp-fixture.mjs']) fails ENOENT — an operational failure, deterministic and restart-free to reverse.

#clauseverdictoracle evidence
1boot COMPLETES; the instance lands as a visible degraded huskpass/api/v1/health200 on the dead upstream. Descriptor: {name:"showcase_mcp_tools", origin:"declarative", state:"degraded", degradedReason:"connector-mcp provider: connector 'showcase_mcp_tools' could not reach its MCP server: spawn node ENOENT", actions:[]}still listed in GET /automation/connectors (5 connectors returned). Boot warn: Connector registered DEGRADED: showcase_mcp_tools (origin: declarative) — no actions and no handlers until its upstream is reachable … (#3017).
2degrade only for upstream-unavailable; a configuration fault stays FATAL — both sidespassDegrade side above. Fatal side: a boot whose connector instance declares provider:'rest' with no provider factory registered abortedERROR Plugin startup failed: com.objectstack.service-automation … no provider factory is registered. Install the connector plugin that supplies it … (ADR-0097) / ✗ Plugin … failed to start - rollback complete. A configuration fault, fatal at boot, carrying the factory's own validation error. (Substituted twin recorded: the item names an invalid providerConfig shape, which would require editing a tracked source; the missing-factory fault is the same classification side.)
3dispatching a degraded instance fails FAST quoting the stored reasonpassCreating a showcase_task returned 201 in 1.2 s (no hang); the trigger-fired run logged Node 'echo' failed: connector_action 'echo': connector 'showcase_mcp_tools' is degraded — … spawn node ENOENT. Dispatch is unavailable until its upstream recovers; the platform retries automatically (#3017). — the stored degradedReason verbatim, clearly distinct from "no such connector/action".
4retries back off from 5 s doubling, no hot looppassObserved attempt timestamps: 23:39:04.056 (1) → 23:39:09.078 (2, +5.02 s) → 23:39:19.092 (3, +10.01 s) → 23:39:39.108 (4, +20.02 s) → 23:40:19.126 (5, +40.02 s). Exactly DECLARATIVE_RETRY_BASE_MS doubling toward the ceiling.
5recovery is ATOMIC and restart-freepassThe node shim was placed at 23:41:21; the next scheduled retry at 23:41:39 logged Connector 'showcase_mcp_tools' replaced with no server restart. Descriptor then: state:"ready", degradedReason gone, actions:["echo_upper"] re-derived from tools/list. A subsequent record-create produced no further dispatch failure.

integration-system.external-datasource-federated-read · rev 1 · pass (3/3)

#clauseverdictoracle evidence
1federated objects in /meta; fixture rows over the normal /data path; filters/$top work as nativepass/meta/object carries showcase_ext_customer and showcase_ext_order, both datasource:"showcase_external". /data/showcase_ext_customer → 3 rows (Aurora Labs / Borealis GmbH / Cyan Pacific); /data/showcase_ext_order → 4 rows. $filter={"region":"EU"} → total 1 (Borealis); $top=2 → 2 of 3; $orderby=lifetime_value desc → 480000 / 312000 / 95000 in order. Federation is transparent to the query layer.
2writes to a read-only external object refused with a located errorpassPOST, PATCH and DELETE all → 403{"code":"EXTERNAL_WRITE_FORBIDDEN","error":"Write '<op>' blocked on object 'showcase_ext_customer': datasource 'showcase_external' is external (schemaMode=external). Requires datasource.external.allowWrites=true (got false) AND object.external.writable=true (got false)."} — names the datasource, the mode and both dials with observed values. Row set byte-identical afterwards (3 rows, same names).
3the connection is visible and health-badgedpassServer-truth oracle (outranks the page, which renders from it): GET /api/v1/datasources → both connections with status:"ok", showcase_external carrying schemaMode:"external", origin:"code", active:true; GET /api/v1/datasources/showcase_external returns the full descriptor with hasSecret:false. Recorded substitution: the clause is worded for the Setup page in an api-surface item; the console page was not driven.

platform-core.seed-integrity · rev 1 · pass (5/5) · P0

#clauseverdictoracle evidence
1every seeded object's row count equals the authored countpassCounts re-derived from the app's own built seed rather than the item's stale prose baseline, then compared to GET /data/<object>?$top=1 totals. 18 of 18 objects match, zero mismatches: account 14 · contact 32 · inquiry 3 · product 4 · project 5 · task 10 · category 4 · business_unit 9 · sys_business_unit 5 · team 2 · project_membership 3 · field_zoo 2 · invoice 12 · invoice_line 5 · expense_report 4 · expense_line 13 · preference 1 · announcement 2. Sum = 130, exactly the boot banner's Seeds: com.example.showcase 130 rows.
2seed VALUES land verbatimpassSpecimen — Full field-by-field: f_multiselect ["red","green"] as a set · f_json {"nested":{"k":"v"},"list":[1,2,3]} structurally intact · f_lookup resolved to the real Northwind id cY2IjNHmR8eYIuvc (f_lookups → Northwind + Contoso) · f_master_detail → the Website Relaunch project id · f_composite, f_repeater, f_record, f_vector, f_location, f_address, f_tags all byte-equal to the authored literal.
3the boot log reports every seed load as successpassFresh boot: zero rejected/failed seed entries; the 3 boot warnings are the known #8686 autonumber tenancy-split notice and two SharingServicePlugin rule-backfill warnings (neither a seed load).
4seed replay is idempotentpassRestart against the SAME file DB → second count sweep diffs empty against the first, all 18 objects. Invoice/invoice-line values re-read identical. (The replay boot emits 8 Field '<x>' is read-only (readonlyWhen) — ignoring incoming change warnings on the upsert path — tax_rate ×5, product/quantity/unit_price; the stored values are unchanged, so no row or value is affected. Noted, not scored as a rejection.)
5deliberately-unseeded fields stay unseededpassf_user, f_users, f_secret all null on both specimens; showcase_task.covernull on every sampled row. Recorded as expected-absent (#4891 / ADR-0104), not as findings.

platform-core.docs-audience-gate · rev 1 · partial

Corpus: exactly one authored book — showcase_manual, audience: "public", 3 groups + a synthetic uncategorized — and 9 docs, none with an explicit audience.

#clauseverdictoracle evidence
1correct refusal distinction — 401 anon vs non-public, 403 non-holder vs permissionSetblocked(fixture)401 half proved: the implicit per-package book com.example.showcase (audience org) → anonymous 401{"error":"This documentation requires sign-in","code":"UNAUTHENTICATED"} — not a bare 404, not an empty-but-200 tree. The 403 half is unsupplied: no {permissionSet}-gated book exists in the corpus, exactly as the item's knownGaps predict.
2the audience vocabulary holds all three waysblocked(fixture)Two of three cells proved: public serves anonymously (showcase_manual → anon 200, 8 entries); org/unset serves any authenticated principal and refuses anonymous (implicit book → admin 200 / plain member 200, both 9 entries; anon 401). The {permissionSet} cell has no fixture — recorded blocked per the clause's own instruction, not passed.
3the permissionSet branch FAILS CLOSED (ADR-0049)passThe spec's own pin was run: packages/spec/src/system/book.test.ts30/30 passed, including line 222 expect(audienceAllows({ permissionSet: 'crm_admin' }, unresolved)).toBe(false) — the exact permissionSets: undefined → false case the clause names. Direct exercise of the exported contract agrees, and an unknown future audience shape also → false.
4gating is TWO-LAYERED — entries additionally filtered by each doc's audiencepassEntry-set diff on the public book: admin 9, member 9, anon 8 — the anonymous tree drops setup_overview and its whole uncategorized group. Every one of the 9 entries the member's tree offers was then fetched as the member: 9/9 resolved 200, zero 401/403. No nav entry refuses on click.
5an UNCLAIMED doc is never exposed through a public bookpasssetup_overview is the unclaimed orphan (synthetic uncategorized group, no authored membership claim). It is absent from the anonymous fetch of the public book's tree, and a direct anonymous GET /meta/doc/setup_overview401, while the claimed showcase_index → 200 anonymously.
6an unknown book name resolves as the implicit per-package book, audience orgpassGET /meta/book/com.example.showcase/tree → admin 200 with a resolved tree (9 entries, group all), anonymous 401. Not a 404.
7doc locale resolution on the tree path; book inline translations refused at authoringpassWith Accept-Language: zh-CN the tree renders showcase_index as "Showcase 示例" with a Chinese description, other entries falling back. BookSchema refuses inline translations: "Inline translations on a book … was removed in spec 17.0.0 (#4667, ADR-0049) — no resolver ever read it … NOTE the near neighbour that DOES work: doc.translations is live" — the trap named explicitly.

Also confirmed from the item's negatives: a repeated ?package= query parameter is refused, not coalesced — 400 {"code":"VALIDATION_ERROR","message":"The \"package\" query parameter was supplied 2 times. Supply it at most once — this endpoint will not choose between conflicting values."}.

records-forms.validation-rule-type-matrix · rev 1 · pass (4/4)

#clauseverdictoracle evidence
1six variants, six distinct ledgered per-type field codespassAll 400 VALIDATION_FAILED, fields[0].code as ledgered: invalid_initial_state (project created status:'completed') · invalid_transition (legal planned→active 200, then illegal active→planned) · invalid_format ×2 (regex tax_id:'BADFORMAT', named-format billing_email:'not-an-email') · json_schema_violation ({tier:'gold',seats:0}) · invalid_json ('{not json') · rule_violation from three rule kinds (conditional, cross_field, script). None inferred from a sibling.
2the error targets the declared fieldpassstate_machine → status (both variants) · format → the formatted field (tax_id, billing_email) · json_schema/invalid_json → support_config · cross_field → start_date, i.e. fields[0], exactly as the spec's own comment says · script/conditional → _record (object-level, correct for a rule declaring no field).
3no rejected write persistspassPost-rejection sweeps of showcase_account and showcase_project: 0 rows matching any rejected probe name. The state_machine probe project remained status: "active" after the illegal transition attempt.
4every happy-path twin lands 2xxpassSix twins, all 201: tax_id:'12-3456789' · billing_email:'ok@example.com' · {tier:'premium',seats:5} · a non-churned account · start_date < end_date · spent inside budget (plus status:'planned' for the FSM entry point). The rules gate violations without blocking legitimate writes.

search.rls-both-personas · rev 2 · pass (4/4)

Persona: a fresh sign-up bound to contributor via sys_user_position; /security/explain confirms positions:["org_member","contributor","everyone"], layer rls verdict narrows, readFilter: {"owner":"<member email>"} — the RLS is genuinely in force.

#clauseverdictoracle evidence
1zero hits for RLS-hidden rows for the member; the identical admin search returns thempassPremise guard first: member GET /data/showcase_invoice/<INV-1003> (linus's) → 404RECORD_NOT_FOUND. Then POST /data/showcase_invoice/query {search:'INV-1003'} → member total 0, records [] (reproduced twice); admin, identical query → total 1, ["INV-1003"].
2no count leakagepassThe member's paged totals never reveal the seeded population (12 invoices; admin's own full read is 12+). With search present the protocol computes a page-local total from the RLS-filtered find: member {search:'INV',limit:2}total 1, n 1 (matching their 1 visible row); admin the same query → total 3, hasMore true (2 returned + 1 more). No response exposes the true population to the restricted persona, and no hit or inflation derives from an invisible row — the item's own definition of the failure. (Wording note for the checklist: the page-local total equals skip + n (+1 when hasMore), so at skip:8 the member sees total 8 with 1 visible row. The number is a function of the caller's own skip and is shaped identically for admin, so it is not a leak — but "reconcile exactly with the rows they can enumerate" only holds while skip ≤ visible count.)
3the restriction is subtractive, not a blackoutpassThe member created their own invoice (INV-QA-RLS) and searching for it returns it — total 1, ["INV-QA-RLS"]; their full visible set is exactly that 1 row.
4RLS composition is structural, proven by the persona splitpassThe evidence set is complete: deny (INV-1003, member 0 hits / admin 1) + allow (admin) + own-rows (member's own invoice) + the by-id premise guard + the explain-derived readFilter.

Reproduction rule — the one fail

cli.migrate-meta-codemod clauses 1 & 2 — the codemod cannot load the sources it is advertised to fix

Mechanism.os migrate meta --from <N> loads and validates the stack config through the current (post-retirement) schema before any conversion runs. A source carrying a retired key — the only input the command exists to fix — fails that load, and the command exits 1 having rewritten nothing. The refusal it prints is the very message that prescribes the command.

Steps (all outside the repo; needs only @objectstack/spec resolvable):

  1. Create a scratch app directory with objectstack.config.ts:
    import{defineStack}from'@objectstack/spec';import{ProbeObject}from'./src/probe.object.js';import{ProbeView}from'./src/probe.view.js';exportdefaultdefineStack({manifest: {id:'com.example.qascratch',namespace:'qa',version:'0.1.0',type:'app',name:'QA Scratch',engines:{protocol:'^17'}},objects: [ProbeObject],views: [ProbeView],});
    src/probe.object.ts — any valid object with an explicit sharingModel.
    src/probe.view.ts — authored as a raw object literal (this is what a real upgrader has on disk; defineView refuses the key at authoring time, which is the same wall):
    exportconstProbeView={name: 'qa_probe_list',label: 'QA Probe List',object: 'qa_probe',list: {type: 'grid',striped: true,columns: [{field: 'name'}]},};
  2. os buildexit 2, and the refusal ends with the prescription:
    ✗ views.0.list.striped: \view.striped` was removed in @objectstack/spec 17.0.0 ([verdict question] view.json list.striped / list.bordered / list.virtualScroll are LIVE on pass-through-only reads — every measured reader copies the key, none applies it #7176, ADR-0049 enforce-or-remove) … Run `os migrate meta --from 16` to rewrite existing sources automatically.`
  3. Run exactly that: os migrate meta --from 16 (with or without --out <path>).
    • Expected — the codemod applies view-list-passthrough-keys-removed (declared by packages/spec/src/migrations/entries/retired-keys/17.ui__ListView__striped.ts), drops striped from views[0].list, and exits 0.
    • Actual→ Loading configuration… then exit 1 with the same message: ✗ defineStack validation failed (1 issue): ✗ views.0.list.striped: … Run \os migrate meta --from 16` to rewrite existing sources automatically.`
  4. os build again → the identical refusal. Nothing was rewritten.

Controls that were run, and rule out the alternatives

  • Reproduced twice, identical output and exit 1 both times.
  • The source's only validation issue is the retired key — an earlier attempt that also carried an unrelated type:'list' mistake was discarded and re-run with type:'grid' so the retired key stands alone. Same result.
  • --from 17 → same load-time refusal. --from 3 → same load-time refusal, i.e. even the ADR-0087 floor check is unreachable on the input that needs it (on a clean tree --from 3 does report Cannot migrate from protocol 3: the chain's support floor is 10).
  • --out is not implicated — reproduced without it.
  • On a config with no retired keys the command works: exit 0, --json{from:16, to:17, runtime:"17.0.0", applied:[{conversionId:"field-required-notnull-explicit", …}], todos:[{id:"action-descriptor-is-async-retired", …}]}. So the command is otherwise functional; it is specifically the retired-key input it cannot ingest.
  • Automation self-check: no browser, no coordinate clicks, no shared tab. The dispatcher-vs-hono-route trap does not apply (CLI, not a route). Stale-dist ruled out — the CLI ran from packages/cli/dist and the refusal text matches current packages/spec/src.

Blast radius.os migrate meta --from 16 is prescribed verbatim by 132 retirement messages across packages/spec/src (--from 17 by 4 more). Every one of them currently sends an upgrader to a command that cannot load their sources. The --stored arm (replaying over sys_metadata rows) is a different code path and was not implicated by this run.


Fixture gaps

Recorded so the next sweep does not rediscover them.

  1. access-security.no-active-org-session-semantics needs a ledger blocked marker. The item has no blocked field, so checklist-select.mjs lists it as runnable — with or without --include-blocked. Three rounds have now paid for the same dead end (deployment is tenancy: single; ADR-0095 Layer 0 inert; OS_TENANCY_POSTURE=isolated correctly refused because @objectstack/organizations is not declared by the host app). Suggested: blocked: { by: "fixture", ref: "#9334" }.
  2. Two shipped showcase sharing rules can never enforce.showcase_project and showcase_task are both sharingModel: 'public_read_write', so share_red_projects_with_execs, share_high_value_red_projects_with_managers and share_open_tasks_with_manager are inert — the platform says so loudly at every boot (SharingServicePlugin: boot rule backfill failed … SHARING_NOT_ENABLED) and at POST /sharing/rules/<name>/evaluate (422). The platform behaviour is correct; the fixture ships three decorative rules. This blocks record-share-grant-revoke clause 7 as written, and it is why sharing-rules-widen had to be driven on share_new_inquiries_with_field_ops / showcase_inquiry instead.
  3. The reports service is not installed on stock showcase.plugin-reports exists in the repo but is absent from the boot's 48-plugin list; every /api/v1/reports* call answers 501 NOT_IMPLEMENTED. This blocks 4 of the 5 clauses of dashboards.saved-report-ownership, whose fixtures block assumes the service is present.
  4. No AI metadata fixture. No stock example app ships agent/tool/skill seeds, so the build-authored round-trip in ai.agent-tool-skill-metadata-roundtrip clause 3 cannot be exercised. A runtime-authored substitute was used and is recorded; note for whoever builds the fixture that the runtime meta read-back does not apply the schema defaults (surface:'ask', active:true) the clause expects.
  5. No {permissionSet}-gated and no doc-level-audience fixture in the docs corpus — only one book (showcase_manual, public) and 9 docs with no explicit audience. Blocks the 403 half of docs-audience-gate clause 1 and the third cell of clause 2.
  6. sys_record_share has no admin edit surface, so sharing-rules-widen §A13 cannot be performed as written (PATCH/DELETE → 405 OBJECT_API_METHOD_NOT_ALLOWED, allowed verbs are read-only; the record-shares routes have no update verb).
  7. No key documented as silently-stripped-by-design was locatable for enforce-or-remove-authoring-gates clause 3; the remaining open shapes are documented .passthrough() (keep-and-forward), which is not the same property.
  8. Breaking a declarative connector's upstream requires either mutating a tracked source or a workspace build. This round worked around it by removing node from the spawned child's PATH; worth writing into the item as the zero-mutation recipe, since it also makes recovery restart-free.

Checklist-accuracy findings & other observations

Moved to the first comment on this issue to keep the body under GitHub's size cap: 9 checklist-text corrections found during the run (none scored as a product failure) and 4 smaller product observations, each independently checkable.


Round complete: 23 / 23 items scored, no item left unreached. Subject pin never moved; nothing landed in the repo.

Filed against #9296.


Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions