Skip to content

QA run · touched:8e3d4a54 (27/52) · c4196099 · 2026-11-13 · 23 PASS / 1 PARTIAL / 3 FAIL / 0 BLOCKED / 25 NOT-RUN #33092

Closed

Description

@objectstack-fleet

qa#3 · os-tesla · session_01L9qibw6yXcLi9aRRBpPT4W

Slice C-api of qa#3's round 2 part C: the delta 8e3d4a5 → c419609 (the release tip after the drained queue), api/cli surface, 27 of 52 items; C-browser files its own record.

Environment fingerprint

Scope

Selector touched:8e3d4a54 resolved at c419609 by node scripts/checklist-select.mjs touched:8e3d4a54 --json → 52 runnable items. This slice = items-C-api.md, 27 items (8 with pins, 19 manual; 3 P0). Every revision below is the one the resolver reported; all 27 matched the item JSON. Every item that also ran in part B kept its revision (no checklist edit in the increment). No planned item is in this slice. Weighting followed ROUND2-C: identity-auth API-key, SCIM and session items, then approvals and automation. Each increment commit got a probe on the live doors it changes (below).

Per-clause tables: record-C-api-clauses-{C1,C2,C3,C4}.md (21–51 KB each; one comment per file).

Item verdicts

item prio tier rev verdict clauses p/f/b/s runner
access-security.no-active-org-session-semantics P0 T1 10 PARTIAL 15/0/1/0 (A0 as A0a–c) C2
access-security.packaged-flow-write-door-parity P1 T2 4 PASS 6/0/0/0 C3
ai.mcp-stdio-fail-closed P0 T1 9 PASS 13/0/0/0 C4
ai.mcp-run-action-exposure-gate P1 T2 7 PASS 7/0/0/0 C4
ai.mcp-oauth-private-host-transport P1 T1 6 PASS 9/0/0/0 C4
api-backend.route-ledger-live-parity P2 T3 4 PASS 12/0/0/0 C4
api-backend.api-methods-verb-gate P1 T2 4 PASS 11/0/0/0 C4
approvals.sla-escalation P2 T3 4 PASS 8/0/0/0 C3
automation.flow-toggle-kill-switch P2 T3 4 PASS 7/0/0/0 C3
automation.trigger-status-contract P1 T2 14 PASS 14/0/0/0 C3
automation.packaged-flow-disable-durable P0 T2 10 PASS 9/0/0/0 C3
automation.packaged-flow-subflow-disable-refusal P1 T2 12 PASS 7/0/0/0 C3
automation.flow-run-semantics-v18 P1 T1 3 PASS 5/0/0/0 C3
cli.migrate-meta-codemod P1 T2 7 PASS 15/0/0/0 C4
identity-auth.self-service-password-reset P1 T2 5 FAIL 9/1/0/0 (1 = security row) C1
identity-auth.two-factor-enrollment-reveal P1 T2 5 PASS 9/0/0/0 C1
identity-auth.two-factor-verify-to-activate P1 T2 5 FAIL 8/1/0/0 (1 = security row) C1
identity-auth.two-factor-backup-codes P1 T2 6 PASS 10/0/0/0 C1
identity-auth.two-factor-disable-lifecycle P1 T2 3 PASS 9/0/0/0 (N3 n/a: the fresh-grace branch was the one observed) C1
identity-auth.api-key-expiry-lifecycle P1 T1 3 PASS 4/0/0/0 C2
identity-auth.import-invitation-and-sso-domain-answers P2 T1 3 PASS 4/0/0/0 C2
identity-auth.idp-group-permission-set-mapping P1 T1 1 FAIL 5/1/1/0 (1 = security row; SSO leg blocked) C2
identity-auth.ai-service-account-lifecycle P1 T1 1 PASS 6/0/0/0 C2
identity-auth.ai-service-account-register-and-key P1 T1 1 PASS 8/0/0/0 C2
integration-system.connector-declarative-boot P1 T1 6 PASS 10/0/0/0 C4
integration-system.connector-degraded-recovery P1 T2 7 PASS 7/0/0/0 C4
records-forms.named-import-mapping P2 T3 1 PASS 4/0/0/0 C4

Pins at c419609, all green, 0 failed. C2: plugin-security 29, rest 5, runtime 14, plugin-auth 197 over 10 files (including #33075's own scim-deactivation-reconcile-user and api-key-restore-guard), plugin-email 32, core 25, dogfood 40 over 5 files. C3: service-automation 72, runtime 52, verify 16, dogfood flow-door-elevated-start 27, plus corroborating plugin-approvals 18 and dogfood approval-decide-over-mcp 8. C4: mcp 27, plugin-auth 114, spec 47, service-automation 110, dogfood 2. C1's items carry no pin. Each clause was scored at its pin's layer (RUNNER rule 6), with the live leg driven where the pin stops above the oracle.

Clauses that did not pass

item clause verdict why
identity-auth.self-service-password-reset probe beside the clauses fail security finding — detail withheld, reported to PM
identity-auth.two-factor-verify-to-activate probe beside the clauses fail security finding — detail withheld, reported to PM
identity-auth.idp-group-permission-set-mapping probe beside the clauses fail security finding — detail withheld, reported to PM
identity-auth.idp-group-permission-set-mapping SSO leg blocked(fixture) no stock OIDC identity provider
access-security.no-active-org-session-semantics A0c (group producer (1) read) blocked(fixture) knownGaps[0], unchanged
identity-auth.two-factor-disable-lifecycle N3 n/a (scored as not contradicted) the fresh-grace branch was the one this run met

Fails: reproduction rule and disposition

  1. identity-auth.self-service-password-reset · probe beside the clauses · security finding — detail withheld, reported to PM.
  2. identity-auth.two-factor-verify-to-activate · probe beside the clauses · security finding — detail withheld, reported to PM.
  3. identity-auth.idp-group-permission-set-mapping · probe beside the clauses · security finding — detail withheld, reported to PM.

No release blocker. All three P0s hold. ai.mcp-stdio-fail-closed passes 13/13. automation.packaged-flow-disable-durable passes 9/9: after a real cold restart the disabled packaged flow reads enabled:false, bound:false, an urgent task starts no run, and re-enable updates the same ledger row. access-security.no-active-org-session-semantics passes every driven clause on hand-booted walled isolated and group postures. Its A7 on group answers a 403 naming the missing organization, not a 500, and NULL-organization row counts stay 0 around every refused write. One leg stays blocked on its known fixture gap.

Extraction (cards drafted, not filed)

No new product card. No clause failed and no non-security probe defect turned up. Re-measured as still present at c419609 (no new draft):

Increment probes (rule 8, on the commits' own doors)

Parked-on #32908 (checklist accuracy and fixture gaps; not extracted)

Stale or wrong item text, no verdict moved:

  • identity-auth.two-factor-enrollment-reveal A4 verify: "a pre-verify bearer answers 401" (QA run · release-18.3.0-w3-manual-api (25/25) · c4c515ad · 2026-10-29 · 23 PASS / 2 PARTIAL / 0 FAIL / 0 BLOCKED / 0 NOT-RUN #27392) does not hold at c419609 on either credential (pre-verify get-totp-uri → 200 or 400). The only 401 is the same token used after verify-totp rotated the session. Re-word: "a token minted before the verify answers 401 after it".
  • identity-auth.self-service-password-reset step 8: the previous_password_hashes ring holds only passwords written while password_history_count > 0. Also, a PASSWORD_REUSE refusal leaves the token usable.
  • identity-auth.self-service-password-reset fixture: /auth/config reports features.emailDelivery:false on stock while reset mail lands in sys_identity_email with status sent. Don't read it as "no mail".
  • identity-auth.two-factor-disable-lifecycle step 8 / A5: mfa_required has three levels, and a gated user cannot restore the setting (403 MFA_REQUIRED) until they enroll again. The grace stamp is written within the disable and survives re-enrollment.
  • identity-auth.two-factor-backup-codes fixtures[0]: "one sequence on one boot" isn't needed; each item drove its own enable → verify.
  • access-security.no-active-org-session-semantics steps[8]: a plain member's showcase_account create meets the generic CRUD 403, and showcase_task now requires project. Name showcase_task (with a project) or showcase_private_note as the tenant-scoped targets. A0 mixes legs of different scorability; scored as A0a–A0c.
  • identity-auth.import-invitation-and-sso-domain-answers fixtures.requires[2]: the domain doors need OS_SSO_ENABLED=true on both boots. sys_identity_email has no template-id column.
  • identity-auth.idp-group-permission-set-mapping steps[3]: with the projected grant present, the hand grant is refused 409 UNIQUE_VIOLATION. Hand-grant first, as the pin's CONTROL does. An unknown set name gets the boundary sentence rather than "no such set".
  • identity-auth.ai-service-account-lifecycle A0/A2 "both reads answer 200": under the agent ceiling a fresh account's key answers 403 (ADR-0151 D3). Name the grant in fixtures.requires; a built-in ceiling set is refused as a person grant (400).
  • Coverage gap: no checklist item asserts fix(plugin-auth): a directory deactivation revokes the user's API keys, and no one restores them #33075's SCIM-deactivation key revocation live (unit pins only). C2's probe above is a ready recipe: mint credential → POST /Users → admin set-user-password → keys → PATCH active false/true → DELETE. Its revoked-key answer is 403 API_KEY_REVOKED, not 401.
  • content/docs/ai/connect-mcp.mdx (9df85e1): "anyone else is refused (FORBIDDEN)" — a caller who cannot read the request gets 404 REQUEST_NOT_FOUND first. That is the safer behaviour; only the wording is imprecise.
  • fix(automation): a flow's answer also names the read-only fields its map items and resumed subflow children dropped (#33019) #33068 reachability: droppedFields over HTTP needs a fixture past the lint (OS_ALLOW_UNLINTED_METADATA_WRITES=1 or a pre-rule stored flow). A future item should name it.
  • automation.trigger-status-contract A5: only the deregistration arm was driven, as in part B.
  • automation.flow-run-semantics-v18 steps[1]: qa_elevated has no recipe body (autolaunched, runAs system, create_record was used).
  • api-backend.route-ledger-live-parity: 16 route ledgers, not 11. REST_ROUTE_LEDGER has 20 families. POST /data/:object/actions/:name is correctly a routing 404; don't sample it as a ledger row.
  • api-backend.api-methods-verb-gate A1 prose: restore/purge follow from delete (unchanged since part B).
  • cli.migrate-meta-codemod step 7: two mode-less decisions, not three. --from 17 --to 19 says nothing about 18 → 19 having no step.
  • ai.mcp-run-action-exposure-gate: admin list_actions is 47 entries (Mei 35), against 45/33 at 8e3d4a5. The clause allows it.
  • records-forms.named-import-mapping A1: say "idempotent per caller". A member re-importing the admin's file creates duplicates on the private-OWD object because the upsert match sees only the caller's rows. That is RLS working.
  • ai.mcp-oauth-private-host-transport steps[3] (real client from a second machine) stays blocked(fixture); it scores no clause.

Environment facts for RUNNER:

  • scripts/qa/qa-fixture.mjs walled-boot runs os dev without --no-compile, so on a shared checkout it rewrites examples/app-showcase/dist/objectstack.json under every other runner. Pass --no-compile there, and have RUNNER's walled-boot entry say so.
  • cd APP && setsid cmd & records the subshell's pid, so kill -- -PID misses the server. Record $! of a bare setsid … & (C4's slip, fixed mid-run; no verdict rests on it).
  • Run vitest at --maxWorkers=1 with dogfood one file per invocation. With 4 runners this gave no OOM kills, against repeated kills with 8 runners at --maxWorkers=2 in part B.

Activity

  1. objectstack-fleet commented on Nov 13, 2026

    @objectstack-fleet
    ContributorAuthor

    qa#3 · os-tesla · session_01L9qibw6yXcLi9aRRBpPT4W

    qa#3 · 18.4.0 R2 part C · C-api · group C1 — subject c419609

    Runner C1, sequential (one server of mine up at a time). Tree boot objectstack dev --seed-admin --no-compile --no-watch from examples/app-showcase, admin admin@objectos.ai / admin123, one port + one file DB per item: 4810 file:/tmp/qa3b/C1/db/identity-auth.self-service-password-reset.db (unarmed boot); 4811 …/identity-auth.two-factor-enrollment-reveal.db, 4812 …/identity-auth.two-factor-verify-to-activate.db, 4813 …/identity-auth.two-factor-backup-codes.db, 4814 …/identity-auth.two-factor-disable-lifecycle.db — all four with OS_AUTH_TWO_FACTOR=true exported BEFORE boot; 4815 …/probe-reset-r2.db (one probe re-run boot for the reset item). Each 2FA boot runs its own enable → session-lane verify sequence, so every item's precondition is established on its own boot (the backup-codes fixture's "one boot for the three enrollment items" is satisfied per boot). No scratch app copies. Row reads = read-only node:sqlite on the boot's DB file (the fixtures' live-boot equivalent of the system-context read); the one write is the reset item's sys_verification.expires_at expiry leg the step prescribes. TOTP = hand-rolled RFC 6238 helper copied from packages/qa/dogfood/test/totp.ts. No item carries automated.ref → no Tier-1 pins (pin totals 0/0). Driver scripts and raw outputs: /tmp/qa3b/C1/bin/*.mjs, /tmp/qa3b/C1/<item>/run*.txt, server logs /tmp/qa3b/C1/<item>/server.log. Revisions in the area JSON match the dispatch (5 / 5 / 5 / 6 / 3). Headline commit c419609 (#33075) changes only reconcileScimUserLifecycle's inactive branch + the API-key restore guard; none of the doors below runs through it, and nothing here moved vs the ee9ad83 round.


    identity-auth.self-service-password-reset (rev 5) — FAIL (every clause pass; one security row)

    pins: none · live: 4810, db identity-auth.self-service-password-reset.db (target user resetee@qa.test created by admin POST /api/v1/auth/admin/create-user → 200; it carried mustChangePassword:true, which did not interfere)

    # clause (short) verdict evidence
    A0 request issues a real reset artifact pass anon POST /api/v1/auth/request-password-reset {email:resetee@qa.test, redirectTo:/reset} → 200 {"status":true,"message":"If this email exists in our system, check your email for the reset link"}; admin GET /api/v1/data/sys_identity_email → row to Reset Tee <resetee@qa.test>, subject "Reset your ObjectStack password", status sent, body_text + body_html carry http://localhost:4810/api/v1/auth/reset-password/<24-char token>?callbackURL=%2Freset; GET /api/v1/data/sys_email → 0 rows (identity mail is deployment-level, as the fixture says); the link answers GET 302
    A1 token completes the reset, rotates the credential pass POST /api/v1/auth/reset-password {token, newPassword} → 200 {"status":true}; sign-in NEW → 200 with token; sign-in OLD → 401 INVALID_EMAIL_OR_PASSWORD; a session opened before the reset → get-session 401 UNAUTHENTICATED (reset ends other sessions)
    A2 consumed token cannot be reused pass same token again with a different password → 400 {"message":"Invalid token","code":"INVALID_TOKEN"}; NEW still signs in 200, the attempted password 401
    A3 expired / tampered token refused loudly, credential unchanged pass fresh request → newest sys_identity_email row → token; UPDATE sys_verification SET expires_at = <now-1h ISO> WHERE identifier = 'reset-password:TOKEN' → 1 changed row (was now+1h); reset → 400 INVALID_TOKEN; current password signs in 200, attempted one 401. Garbage token, last-char-tampered token, "" and missing token → each 400 INVALID_TOKEN; current password still 200, attempted 401
    A4 no account-existence leak pass known vs unknown (nobody-here@qa.test) responses byte-identical (200, same body, no Set-Cookie), with and without redirectTo; unknown wrote no sys_identity_email row (rows only to the known address)
    N0 (neg) 2xx reset leaving password unchanged pass A1: new signs in, old refused
    N1 (neg) reused/expired token still resets pass A2/A3 both refused, credential unchanged
    N2 (neg) known vs unknown differ pass A4 identical
    N3 (neg) silent success on garbage token pass A3 named 400 INVALID_TOKEN
    Step 8 (reuse prevention, no clause): password_history_count defaults 0 (GET /api/settings/auth), so it was armed for the step: PUT /api/settings/auth {values:{password_history_count:3}} → 200; reset to the CURRENT password → 400 PASSWORD_REUSE "For security you can't reuse one of your last 3 passwords…" (token survives the refusal: the same token then reset to another password 200); after that, resetting back to the previous password → 400 PASSWORD_REUSE; restored to 0 → 200. A password set while the count was 0 was not in the ring (accepted) — see Parked.
    S1 identity-auth.self-service-password-reset · probe beside the clauses · security finding — detail withheld, reported to PM fail —
    Probe (~4 min, anon + target user + admin, doors request-password-reset / reset-password / the GET link / sys_identity_email): {} / {email:""} / {email:"not-an-email"} → 400 VALIDATION_ERROR (shape-level, identical for any address, no enumeration); upper-cased known address → 200 same body, mail delivered to the stored lowercase address; GET /reset-password/garbage?callbackURL=/reset → 302 Location: …/reset?error=INVALID_TOKEN; newPassword:"short" → 400 PASSWORD_TOO_SHORT; spec name POST /api/v1/auth/forget-password → 404 (the live/spec divergence step 1 names, still present); target user reading sys_identity_email → 403 PERMISSION_DENIED, anon → 401.

    identity-auth.two-factor-enrollment-reveal (rev 5) — PASS

    pins: none · live: 4811, db identity-auth.two-factor-enrollment-reveal.db, OS_AUTH_TWO_FACTOR=true before boot

    # clause (short) verdict evidence
    A0 assert-armed guard pass GET /api/v1/auth/config → 200 {success:true, data.features.twoFactor:true}; anon POST /api/v1/auth/two-factor/enable {} → 400 VALIDATION_ERROR (non-404)
    A1 totpURI parseable pass POST /two-factor/enable {password:admin123} (cookie session) → 200 keys [method, totpURI, backupCodes]; otpauth://totp/ label ObjectStack:admin@objectos.ai, params secret/issuer=ObjectStack/digits=6/period=30, algorithm absent (SHA-1 default); secret length 52, base32Decode clean → 32 bytes
    A2 backupCodes non-empty string[] pass Array(10), all strings, shape xxxxx-xxxxx (values not recorded)
    A3 sys_two_factor.enable_two_factor paths resolve pass GET /api/v1/meta/object/sys_two_factor → enable_two_factor type api target /api/v1/auth/two-factor/enable, resultDialog.fields [{totpURI, qrcode}, {backupCodes, code-list}], acknowledge "I have saved my backup codes"; response.totpURI string, response.backupCodes string[10]
    A4 get-totp-uri re-reveal recorded pass (record) pre-verify, cookie: correct pw 200 SAME secret · wrong pw 400 INVALID_PASSWORD · no pw 400 VALIDATION_ERROR · anon 401. Pre-verify, bearer-only client (sign-in token as Authorization: Bearer, no cookie): correct 200 SAME secret · wrong 400 INVALID_PASSWORD. Session-lane verify-totp → 200 {token, user} + rotated better-auth.session_token; post-verify cookie (rotated): correct 200 SAME secret · wrong 400 · none 400; post-verify echoed-token bearer: correct 200 SAME · wrong 400; the pre-verify token after rotation → 401 (get-session 401). Password-gated in every cell → no carve-out
    A5 sys_user.enable_two_factor declares the reveal pass GET /api/v1/meta/object/sys_user → enable_two_factor resultDialog fields [{totpURI, qrcode}, {backupCodes, code-list}] + acknowledge, successMessage absent; both paths resolve (string / string[10])
    N0 (neg) wrong-pw / anon enable succeeds pass wrong pw → 400 INVALID_PASSWORD, select count(*) from sys_two_factor where user_id=admin = 0; anon {password} → 401 UNAUTHORIZED, count 0
    N1 (neg) scoring with guard unmet pass guard met first
    N2 (neg) un-gated re-reveal pass not observed — every re-reveal cell password-gated
    Probe (~2 min, same doors on the 4812 boot after activation — the 4811 boot held no retained factor for a second sign-in): enable on an ACTIVE enrollment → 400 TOTP_ALREADY_ENABLED (the secret cannot be silently re-rolled; old secret still completes the challenge); a challenge-cookie-only client (no session) → get-totp-uri / generate-backup-codes / disable each 401 (4814). No findings.

    identity-auth.two-factor-verify-to-activate (rev 5) — FAIL (every clause pass; one security row)

    pins: none · live: 4812, db identity-auth.two-factor-verify-to-activate.db, OS_AUTH_TWO_FACTOR=true before boot

    # clause (short) verdict evidence
    A0 assert-armed guard pass /api/v1/auth/config 200 {success:true, data.features.twoFactor:true}; anon enable {} → 400
    A1 fresh enrollment INERT at sign-in pass after enable, fresh-client POST /api/v1/auth/sign-in/email → 200 keys [redirect, token, user, session], no twoFactorRedirect, Set-Cookie only better-auth.session_token (no two_factor cookie); that client's get-session 200 and data read 200; sys_user.two_factor_enabled = 0. TOTP_NOT_ENABLED cited from two-factor-lockout.dogfood.test.ts, not driven
    A2 latent-default probe (record) pass (record) row read at 2026-11-13T10:15:15.963Z, between enable and verify: verified = 0 (integer), failed_verification_count 0, locked_until NULL — the healthy reading; defaultValue:true did not leak
    A3 one correct TOTP on session lane activates pass (after the negative leg) POST /two-factor/verify-totp {code} with the live session → 200 {token, user} + rotated session cookie; row verified = 1, failed_verification_count 0; sys_user.two_factor_enabled = 1; rotated session get-session 200
    A4 activation makes the challenge real pass fresh sign-in → 200 {"twoFactorRedirect":true,"twoFactorMethods":["totp"]}, Set-Cookie clears session_token/session_data and sets better-auth.two_factor; that client's get-session 401 UNAUTHENTICATED and GET /api/v1/data/showcase_task 401; cookie-lane verify-totp → 200 {token:string, user} (+ session cookie, two_factor cleared); get-session then 200 admin@objectos.ai, data read 200
    N0 (neg) never-verified enrollment satisfying a challenge pass no challenge exists pre-verify (A1)
    N1 (neg) session-lane verify bumps lockout counter pass wrong code on the session lane ×2 → 401 INVALID_CODE each; failed_verification_count stayed 0, verified 0, two_factor_enabled 0; enable re-driven → 200, NEW secret, still one row, verified 0; old-secret code then 401; end-of-run counter 0
    N2 (neg) unarmed boot pass guard met
    S1 identity-auth.two-factor-verify-to-activate · probe beside the clauses · security finding — detail withheld, reported to PM fail —
    Probe (~2 min, admin at the challenge + no-credential caller): verify-totp with no cookie and no session → 401 INVALID_TWO_FACTOR_COOKIE; a wrong code at the CHALLENGE does count (0 → 1) and a correct one at the same challenge resets it to 0 (contrast with N1 — the isSignIn split holds both ways); enable on the active enrollment → 400 TOTP_ALREADY_ENABLED.

    identity-auth.two-factor-backup-codes (rev 6) — PASS

    pins: none · live: 4813, db identity-auth.two-factor-backup-codes.db, OS_AUTH_TWO_FACTOR=true before boot

    # clause (short) verdict evidence
    A0 guard + verified-enrollment precondition pass /api/v1/auth/config 200 {success:true, data.features.twoFactor:true}; anon enable {} → 400; enable 200 (10 codes retained) → session-lane verify-totp 200 → row verified 1, two_factor_enabled 1
    A1 backup code completes the challenge pass sign-in → twoFactorRedirect true + better-auth.two_factor cookie → POST /two-factor/verify-backup-code {code:B0} through the cookie → 200 {token, user}; get-session 200 admin@objectos.ai, data read 200
    A2 single-use, per-code pass fresh challenge, B0 again → 401 INVALID_BACKUP_CODE (get-session 401, data 401); fresh challenge, B1 → 200 (control: get-session 200, data 200)
    A3 regeneration invalidates the prior set wholesale pass signed-in POST /two-factor/generate-backup-codes {password} → 200 keys [status, backupCodes], 10 new strings, 0 overlap with the old set; fresh challenges: old UNSPENT B4 → 401 INVALID_BACKUP_CODE, old unspent B9 → 401; new N0 → 200 + usable session
    A4 storage shape (record) pass (record) DB-file read of sys_two_factor.backup_codes: one string of 334 chars, alphanumeric only, not JSON-parsable, contains none of the 20 issued codes (with or without the dash) → an opaque ciphertext, the healthy reading. (Beside it: the secret column is 144 chars and does not contain the base32 secret.)
    A5 sys_user.generate_backup_codes reveals the new set pass GET /api/v1/meta/object/sys_user → generate_backup_codes type api target /api/v1/auth/two-factor/generate-backup-codes, refreshAfter false, successMessage absent, resultDialog fields [{backupCodes, code-list}], acknowledge "I have saved the new codes", copy "Previous backup codes are now invalid…"; response.backupCodes = string[10]. (sys_two_factor.regenerate_backup_codes: same dialog, refreshAfter true)
    N0 (neg) code spendable twice pass A2 refusal
    N1 (neg) old set surviving regeneration pass A3 refusals (two old unspent codes)
    N2 (neg) wrong-pw / anon regenerate succeeds pass wrong pw → 400 INVALID_PASSWORD; anon → 401 UNAUTHORIZED
    N3 (neg) refused regenerate still rotated the set pass after the refused wrong-pw regenerate, old B2 spends 200 at a fresh challenge (and B3 too)
    Probe (~2 min, admin at the challenge / session lane): "", upper-cased, dash-less and space-padded codes → 401 INVALID_BACKUP_CODE each (no lenient normalisation); verify-backup-code on the SESSION lane (live session, no challenge cookie) → 200 and the code is consumed (the same code at a later challenge → 401) — single-use holds across lanes. No findings.

    identity-auth.two-factor-disable-lifecycle (rev 3) — PASS

    pins: none · live: 4814, db identity-auth.two-factor-disable-lifecycle.db, OS_AUTH_TWO_FACTOR=true before boot · mfa_required leg = Setup patch on the same boot (PUT /api/settings/auth), restored to off / 7 at the end (read back)

    # clause (short) verdict evidence
    A0 guard + active-enrollment precondition pass /api/v1/auth/config 200 {success:true, data.features.twoFactor:true}; enable 200 + session-lane verify 200 → rows 1, verified 1, two_factor_enabled 1; BEFORE: fresh sign-in → 200 {"twoFactorRedirect":true,"twoFactorMethods":["totp"]} + better-auth.two_factor cookie
    A1 wrong-pw disable refused server-side, no-op pass POST /two-factor/disable {password:"wrong-password"} → 400 INVALID_PASSWORD; rows 1, verified 1, two_factor_enabled 1; fresh sign-in still twoFactorRedirect true; caller's session still 200
    A2 correct disable tears down completely pass POST /two-factor/disable {password} → 200 {"status":true} + rotated better-auth.session_token; select count(*) from sys_two_factor where user_id=… = 0; sys_user.two_factor_enabled = 0
    A3 next sign-in unchallenged pass POST /auth/sign-in/email → 200 keys [redirect, token, user, session], no twoFactorRedirect, Set-Cookie only better-auth.session_token; GET /api/v1/data/showcase_task → 200
    A4 stale codes dead (and after re-enable) pass retained backup code post-disable: no cookie → 401 INVALID_TWO_FACTOR_COOKIE; live session → 400 BACKUP_CODES_NOT_ENABLED; old-secret TOTP with live session → 400 TOTP_NOT_ENABLED. Re-enable → 200, fresh secret, 10 fresh codes (0 overlap); old-secret TOTP on the new enrollment's session lane → 401 INVALID_CODE (verified stays 0); new code → 200, echoed token get-session 200. At new challenges: old-secret TOTP → 401 INVALID_CODE, old backup code → 401 INVALID_BACKUP_CODE, new TOTP → 200 (control)
    A5 mfa_required re-gate follows the grace clock (record) pass (record) PUT /api/settings/auth {values:{mfa_required:"everyone"}} → 200 (levels off/administrators/everyone), grace 7, features.twoFactor true. Enrolled user: get-session authGate null, data 200, mfa_required_at NULL (enrolled user never stamped). Disable under mfa_required → 200 {"status":true} (NOT refused), rows 0, two_factor_enabled 0; pre-disable bearer → 401 (rotated); stamp written lazily 2026-11-13T10:18:31.012Z at the first unenrolled sight. Re-sign-in → no twoFactorRedirect, get-session authGate null, data 200 → fresh 7-day window branch. mfa_grace_period_days:0 → 200; new session: get-session 200 user.authGate {code:MFA_REQUIRED, "Multi-factor authentication is required…"}, data read 403 {"error":{"code":"MFA_REQUIRED",…}} (the earlier in-grace session also 403 now). While gated: POST /two-factor/enable → 200 [method, totpURI, backupCodes], get-totp-uri 200, verify-totp 200 → authGate null, data 200 (not stranded). Stamp after re-enroll still 10:18:31.012Z (never cleared, as the source says). Gated PUT /api/settings/auth {mfa_required:"off"} → 403 MFA_REQUIRED; after re-enroll the restore → 200, read back off / 7
    N0 (neg) wrong-pw disable / row-vs-challenge mismatch pass A1; A2 + A3 agree (row gone and challenge gone)
    N1 (neg) stale codes verify anything pass A4, all refused
    N2 (neg) gated user's /two-factor/* unreachable pass enable / get-totp-uri / verify-totp all 200 while gated
    N3 (neg) filing an immediately-biting gate as "no grace" n/a (not filed) observed branch was the fresh-window one (no prior stamp)
    Probe (~1 min, anon + half-signed-in client): anon disable {password} → 401; a challenge-cookie-only client (two_factor cookie, no session): disable {password} → 401, generate-backup-codes 401, get-totp-uri 401, disable {} → 400 VALIDATION_ERROR (body schema before auth, as on enable); the factor survived (still challenged). No findings.

    Fails

    None.

    Parked

    • two-factor-enrollment-reveal A4 verify — the "pre-verify bearer answers 401" sentence (QA run · release-18.3.0-w3-manual-api (25/25) · c4c515ad · 2026-10-29 · 23 PASS / 2 PARTIAL / 0 FAIL / 0 BLOCKED / 0 NOT-RUN #27392) does not hold at c419609, on either credential. A bearer-only client carrying the sign-in token answered pre-verify get-totp-uri 200 (same secret) / 400 INVALID_PASSWORD, exactly like the cookie session. The only 401 seen was that SAME pre-verify token used AFTER verify-totp rotated the session (get-session 401 too). So QA run · release-18.3.0-w3-manual-api (25/25) · c4c515ad · 2026-10-29 · 23 PASS / 2 PARTIAL / 0 FAIL / 0 BLOCKED / 0 NOT-RUN #27392's 401 reads as a rotated-out token, not a pre-verify property; the clause should say "a token minted before the verify answers 401 after it", or drop the sentence.
    • self-service-password-reset step 8: the previous_password_hashes ring only holds passwords written while password_history_count > 0: with the count armed after two resets, the then-current password was refused (PASSWORD_REUSE) but a password set while the count was 0 was accepted. The step's "resetting to the OLD password is refused" holds only for an old password set after arming; the step could say so. A PASSWORD_REUSE refusal leaves the token usable (the same token then reset successfully).
    • self-service-password-reset fixture: /auth/config reports features.emailDelivery:false on the stock boot while the reset mail is written to sys_identity_email with status sent (dev log transport). A runner reading emailDelivery:false as "no mail" would wrongly block the token capture; the fixture could mention it.
    • disable-lifecycle step 8 / A5: confirmed again at c419609: mfa_required has three levels (off / administrators / everyone; the run used everyone); a gated user cannot restore the setting (PUT /api/settings/auth → 403 MFA_REQUIRED) until they enroll again, so "restore both values afterwards" needs a re-enrollment first. The lazy stamp was written within the disable's own exchange (already present immediately after the 200), and survives re-enrollment.
    • two-factor-backup-codes fixture[0]: "run the three enrollment items as one sequence on one boot" — each item here was run on its own boot/DB per the wave rule, each driving its own enable → verify; nothing in the clauses needs the shared boot.
  2. objectstack-fleet commented on Nov 13, 2026

    @objectstack-fleet
    ContributorAuthor

    qa#3 · os-tesla · session_01L9qibw6yXcLi9aRRBpPT4W

    qa#3 · 18.4.0 R2 part C · C-api · group C2 — subject c419609

    Runner C2, 6 items, every revision as dispatched (area JSON matches: no-active-org-session-semantics 10, api-key-expiry-lifecycle 3, import-invitation-and-sso-domain-answers 3, idp-group-permission-set-mapping 1, ai-service-account-lifecycle 1, ai-service-account-register-and-key 1). Live boots, one at a time, all from the tree examples/app-showcase with OS_PORT=P OS_STORAGE_LOCAL_ROOT=/tmp/qa3b/C2/storage/<name> setsid pnpm exec objectstack dev --seed-admin -p P -d file:/tmp/qa3b/C2/db/<name>.db --no-compile --no-watch, stopped by process group (helpers /tmp/qa3b/C2/bin/boot.sh, stop.sh): :4820 noorg-isolated.db (walled isolated), :4821 noorg-group.db (walled group) — both + OS_PLATFORM_OWNER_EMAIL=admin@objectos.ai OS_AUTH_MEMBERSHIP_POLICY=invite-only; :4822 identity.db (OS_SCIM_ENABLED=true; shared by api-key-expiry, both service-account items, idp-group mapping and the #33075 SCIM probe, each on its own personas/keys/names — deviation from one-DB-per-item, the service-account fixtures chain); :4823 sso-on.db (OS_SSO_ENABLED=true OS_SSO_DOMAIN_VERIFICATION=true) then sso-off.db (OS_SSO_ENABLED=true only), env read from the listener's /proc environ. No scratch copies needed. Server logs /tmp/qa3b/C2/logs/*.log. Pin logs /tmp/qa3b/C2/pins/.log (unit, one vitest run per package, --maxWorkers=1) and /tmp/qa3b/C2/pins/df/.log (dogfood, one file per invocation). Pin totals, all at c419609, 0 failed: plugin-security 2 files 29/0; rest 1 file 5/0; runtime 1 file 14/0; plugin-auth 10 files 197/0 (the 8 named by the items + the headline's scim-deactivation-reconcile-user.test.ts and api-key-restore-guard.test.ts); plugin-email 1 file 32/0; core 1 file (api-key-lifecycle.test.ts, headline corroboration) 25/0; dogfood 5 files: no-active-organization-write-refusal 6/0, sharing-rule-org-less-caller 16/0, api-key-lifecycle 3/0, service-account-register-key 10/0, service-account-lifecycle 5/0. (The non-TTY reporter prints per-package totals only; per-file counts not separated for multi-file packages.) No release blocker. No clause FAIL. One probe finding beside the clauses on the #33075 surface is handed to PM privately (security carve-out) — no detail here.

    item verdict pass fail blocked
    access-security.no-active-org-session-semantics (P0) PARTIAL 15 0 1 (A0c, fixture)
    identity-auth.api-key-expiry-lifecycle (P1) PASS 4 0 0
    identity-auth.import-invitation-and-sso-domain-answers (P2) PASS 4 0 0
    identity-auth.idp-group-permission-set-mapping (P1) PARTIAL 5 0 1 (A3, fixture)
    identity-auth.ai-service-account-lifecycle (P1) PASS 6 0 0
    identity-auth.ai-service-account-register-and-key (P1) PASS 8 0 0

    access-security.no-active-org-session-semantics (rev 10, P0) — PARTIAL (every driven clause pass; one sub-leg, A0c, blocked(fixture) — knownGaps[0] producer (1))

    pins: plugin-security no-active-organization-write-refusal.test.ts + no-active-organization-insert-union-scope.test.ts → 2 files 29/0 (plugin unit, stub terminal; and ENGINE, real ObjectQL + SqlDriver) · dogfood no-active-organization-write-refusal.dogfood.test.ts → 6/0 (HTTP, in-process posture-only isolated showcase) · sharing-rule-org-less-caller.dogfood.test.ts → 16/0 (HTTP, in-process showcase). No clause scored from the plugin-unit pin alone (rule 6): every clause below is scored on the live walled boots.
    live: two walled boots from the tree, by hand (posture env + --no-compile, not qa-fixture.mjs walled-boot, which boots os dev WITHOUT --no-compile — see Parked): OS_TENANCY_POSTURE=isolated on :4820 db /tmp/qa3b/C2/db/noorg-isolated.db, OS_TENANCY_POSTURE=group on :4821 db /tmp/qa3b/C2/db/noorg-group.db; both OS_PLATFORM_OWNER_EMAIL=admin@objectos.ai OS_AUTH_MEMBERSHIP_POLICY=invite-only. Wall proven each boot: banner Tenancy: isolated|group, 0 DEGRADED lines, GET /api/v1/auth/config → features {multiOrgEnabled true, tenancyPosture isolated|group, degradedTenancy false}. Operator = seeded admin, active org = Default Organization (A); operator created org B (POST /auth/organization/create 200) and staged account/project/task in B and account/project in A. Persona = producer (2): member minted by operator POST /auth/admin/create-user while A active → 200 {membershipCreated:true, organizationId A}; change-password; POST /auth/organization/set-active {"organizationId":null} → 200 null; GET /auth/get-session → session.activeOrganizationId null (isolated M1 CPlmT4gY…, group G1 n5ps14Wd…). Producer (1) (member-less user) has no measured walled door (knownGaps[0]) → not driven. NULL-org counts read read-only from each boot's SQLite file over all 69 tables carrying organization_id (/tmp/qa3b/C2/bin/nullcount.py).

    # clause (short) verdict evidence
    A0a isolated: tenant-scoped reads → 200 empty, not an error (producer 2) pass control with A active: GET /data/showcase_task → 200 total 10 orgs [A]; showcase_account 200 total 15. After clearing: GET /data/showcase_task, showcase_account, showcase_private_note, showcase_project → each 200 {"records":[],"total":0,"hasMore":false}; by-id of a B task and of an A task → 404 RECORD_NOT_FOUND
    A0b group, producer (2): 200 with own memberships' rows only pass G1 (member of A only), no active org: GET /data/showcase_task?limit=200 → 200 total 11, organization_id set [A], QA-C2-G-B-task absent; showcase_project → 200 total 6 orgs [A]; by-id of B task → 404; ?filter={"organization_id":B} → 200 total 0 (the posture's union wall, as the clause allows)
    A0c group, producer (1): empty membership set → 200 empty blocked(fixture) knownGaps[0]: no measured HTTP door mints a member-less user on a walled boot since #25626 (create-user joins the operator's active org)
    A1 writes refused 4xx naming the missing org, ADR-0112 code pass isolated, M1 no org: POST /data/showcase_task {"title":"QA-C2-M1-noorg","project":A-proj} → 403 {"error":"[Security] Access denied: 'showcase_task' is scoped to an organization, and this session has no active organization — so this insert has no organization to place the record in. Join or select an active organization and retry.","code":"PERMISSION_DENIED","object":"showcase_task"}; same sentence for POST showcase_private_note, POST showcase_task/createMany [2 rows], and a create carrying a forged organization_id A or "". Control: same member with A active → showcase_private_note 201 org A, showcase_task 201 org A. (Driving note: PATCH/DELETE of an existing task and showcase_account create answer the generic 403 "You do not have permission…" with AND without an active org — the member CRUD gate, not this refusal; not scored)
    A2 no NULL stamping, no row left pass isolated: NULL/empty organization_id rows 0 of 592 before, 0 after every refused write (597 total at end = only the org-A control rows); readback as M1 (filter title like QA-C2%) → 200 empty. group: 0/593 before and 0/593 after the A7 creates
    A3 org-scoped holder with no active org reads/writes nothing cross-tenant pass operator PUT /meta/permission/qa_c2_sharing_admin {objects:{}, systemPermissions:[manage_sharing]} → 200; POST /data/sys_user_permission_set {user M1} → 201 (row org A); rules qa_c2_rule_b (raw row org B) and qa_c2_rule_a (org A) created by the operator. M1 no org: /auth/me/permissions → tenantId null, systemPermissions [] (grant confers nothing); GET /sharing/rules, GET …/qa_c2_rule_b, POST …/evaluate, DELETE …, POST /sharing/rules → all 403 {"code":"PERMISSION_DENIED","error":"sharing-rule administration requires the manage_sharing capability (ADR-0111 D6)"}; rule B still stored. Data: PATCH B's showcase_account and POST with organization_id B → 403; GET B account by id → 404; row unchanged. Control M1 with A active: systemPermissions [manage_sharing]; GET /sharing/rules → 200, rules all org A (qa_c2_rule_a + 2 shipped), B absent; by-name B → 404 RULE_NOT_FOUND
    A4 audit ledger read via object permission pass (NULL attribution recorded, knownGaps[3], producer 2) isolated: raw telemetry-DB sys_audit_log rows for M1 = 11 (login A ×3, logout tenant_id NULL — sent while the session had no active org, create ×2 A, update sys_user NULL ×5); operator GET /data/sys_audit_log?filter={"user_id":M1} → 200 total 11, same rows and tenant_ids; M1's same read → 403 PERMISSION_DENIED object sys_audit_log. group: raw sys_session rows for G1 [login A, login A, logout NULL, login A] = operator read total 4, same tenant_ids; G1 → 403
    A5 state is legal, nothing 5xx pass get-session 200 (org null); GET /auth/me/permissions 200 (tenantId null); GET /data/sys_user/‹self› 200 and PATCH name → 200 (non-tenant object); GET /data/sys_organization → 200 empty; GET /meta/object/showcase_task 200; no 5xx in either run; 0 Unhandled / SystemWriteOrganizationRequired lines in either boot log
    A6 three subsystems agree pass sharing: org-less holder refused 403 at the capability gate (grant confers nothing without an org) · wall + write path: reads empty (isolated) / own-membership only (group), writes 403 naming the missing org, 0 NULL rows · audit: rows written incl. the no-org logout, operator reads all (raw = read), user 403 by object permission. No disagreement; ledger NULL attribution recorded per knownGaps[3], not a disagreement (D7)
    A7 walled group: member with memberships, no active org, create never 5xx, no NULL pass group boot: G1 control with A active POST /data/showcase_task {project A} → 201 org A; set-active null → get-session activeOrganizationId null; POST /data/showcase_task {"title":"QA-C2-G1-noorg","project":"2ujhx9NK1jzEKooS"} → 403 PERMISSION_DENIED with the same "no active organization" sentence (not the #24784 500); showcase_private_note same; NULL count 0/593 before and after; no QA-C2-G1-noorg row produced (only the control row, org A)
    N0 fell open (#8158) pass A3
    N1 NULL-stamped invisible row (#8208) pass A2
    N2 invisible audit rows pass operator reads every raw row incl. the NULL-attributed logout (D7 reading)
    N3 500 / unhandled rejection pass none observed (A5)
    N4 refusal not naming the missing org pass message names "this session has no active organization"
    N5 invented error code pass PERMISSION_DENIED
    Probe (~3 min, isolated M1 and group G1, no active org): set-active to org B (non-member) and to an unknown id → 403 USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION (better-auth's own code, outside ADR-0112 — known shape, note only), session stays null; POST /api/v1/keys → 400 VALIDATION_ERROR "Cannot create an API key without an active organization: this deployment runs a walled tenancy posture ('isolated')…" (names the org); GET /data/showcase_task/ (empty id) → 404 ENDPOINT_NOT_FOUND; create with organization_id "" → the same 403; group member filtering on organization_id = B → 200 total 0. No finding.

    identity-auth.api-key-expiry-lifecycle (rev 3, P1) — PASS

    pins: rest src/rest-api-key-lifecycle.pin.test.ts → 5/0 (unit: HTTP stub + fake ql) · runtime src/api-key-lifecycle.pin.test.ts → 14/0 (unit: fake objectql + fake kernel) · dogfood api-key-lifecycle.dogfood.test.ts → 3/0 (HTTP, in-process booted showcase) · live: :4822, db /tmp/qa3b/C2/db/identity.db (auth settings at defaults). Clock-skew trap ruled out: server Date header 10:19:41 GMT = local date -u 10:19:41.

    # clause (short) verdict evidence
    A0 pins green ([test]) pass 5/0, 14/0, 3/0 at c419609 (layers above; scores only what they assert)
    A1 default ≈90 days, echoed pass POST /api/v1/keys {"name":"qa-c2-default"} at 10:19:46Z → 201 data.expires_at 2027-02-11T10:19:46.302Z = +90 d
    A2 beyond ceiling → 400 API_KEY_EXPIRY_TOO_LONG, nothing minted pass expires_at +400 d → 400 {"code":"API_KEY_EXPIRY_TOO_LONG","message":"expires_at 2027-12-18T10:19:46.000Z is further away than this deployment allows: … Maximum key lifetime is 365 days, so the latest expiry a key signed now may have is 2027-11-13T10:19:46.393Z…"}; GET /api/v1/data/sys_api_key total 2 before, 2 after (control +365 d −1 min → 201)
    A3 revoked 403 API_KEY_REVOKED, expired 403 API_KEY_EXPIRED pass key used 200, revoked by the admin key action (PATCH /data/sys_api_key/{id} {revoked:true} → 200) → next GET /data/showcase_task 403 {"error":{"code":"API_KEY_REVOKED","message":"This API key (osk_VxF65Bnv…) was revoked. Its owner or a platform administrator can restore it…"}}; key minted with expires_at +5 s used 200, after lapse → 403 {"error":{"code":"API_KEY_EXPIRED","message":"This API key (osk_RZnpzv_H…) expired on 2026-11-13T10:19:56.000Z. Keys do not renew…"}}; made-up osk_ key → 401 UNAUTHENTICATED (distinct)
    Probe (~2 min, admin): expires_at in the past → 400 VALIDATION_ERROR "must be in the future"; "not-a-date" → 400 VALIDATION_ERROR "must be a parseable date"; {} → 201 name "API Key" + 90-day default; GET /keys → 405 METHOD_NOT_ALLOWED (as the clause says); the ceiling exactly (+365 d −1 min) → 201. No finding.

    identity-auth.import-invitation-and-sso-domain-answers (rev 3, P2) — PASS

    pins: plugin-auth sso-domain-verification-unknown-provider.pin.test.ts, admin-import-users-invite-link.test.ts, admin-import-users-account-invitation.test.ts (in the 10-file plugin-auth run 197/0; unit — the SSO pin is real AuthPlugin on Hono over createMemoryEngine) · plugin-email auth-account-invitation-template.test.ts → 32/0 (unit) · live: :4823 sso-on.db (OS_SSO_ENABLED=true OS_SSO_DOMAIN_VERIFICATION=true) and sso-off.db (OS_SSO_ENABLED=true only); env proven from /proc//environ. OS_SSO_ENABLED set on both (the door is SSO-gated; see Parked) so only the domain-verification flag differs.

    # clause (short) verdict evidence
    A0 pins green ([test]) pass plugin-auth 10 files 197/0 (incl. the 3), plugin-email 32/0, at c419609
    A1 import uses account_invitation; self reset uses password_reset pass POST /auth/admin/import-users {format json, mode insert, passwordPolicy auto, rows:[qa.c2.invitee@example.com]} → 200 delivery.emailInvite 1; sys_identity_email (admin read, status sent): subject "Dev Admin invited you to ObjectStack", body "Dev Admin (admin@objectos.ai) has created an account for you … with the email address qa.c2.invitee@example.com. Set your password (link expires in 60 minutes): http://localhost:4823/api/v1/auth/reset-password/KLCy…?callbackURL=…%2F_console%2Freset-password" + the Forgot-password fallback naming the inviter. Self-service POST /auth/request-password-reset {qa.c2.exist@…} → 200; mail subject "Reset your ObjectStack password", "Reset your password (link expires in 60 minutes)". sys_identity_email stores no template id (no column) — told apart by the two templates' subjects/bodies
    A2 invite link → /_console/reset-password?token= and works pass GET the mailed link → 302 Location http://localhost:4823/_console/reset-password?token=KLCyKWHPrtz6CNBB3yWbhjL9 (no ?error); POST /auth/reset-password {newPassword, token} → 200 {"status":true}; sign-in with the new password → 200 with token; token reuse → 400 INVALID_TOKEN (control: a bad token link → 302 …?error=INVALID_TOKEN)
    A3 unknown providerId: 404 on, 400 off pass ON: POST /auth/admin/sso/request-domain-verification {providerId:"qa-no-such-provider"} → 404 {"code":"RESOURCE_NOT_FOUND","message":"SSO provider \"qa-no-such-provider\" was not found."}; verify-domain same. OFF: both → 400 {"code":"DOMAIN_VERIFICATION_DISABLED","message":"Domain verification is not enabled (set OS_SSO_DOMAIN_VERIFICATION)."}
    Probe (~2 min, admin/anon): import of an existing email → row ok:false "User already exists. Use another email."; "not-an-email" → row ok:false "is not a valid email"; providerId "" → 400 INVALID_REQUEST "Missing required field: providerId"; anonymous request-domain-verification → 401. No finding.

    identity-auth.idp-group-permission-set-mapping (rev 1, P1) — FAIL (SCIM legs pass; SSO leg blocked(fixture); one security row)

    pins: plugin-auth scim-group-projection.test.ts (in the 10-file plugin-auth run 197/0; plugin unit + engine over a SQL driver, AuthManager.handleRequest()) — no pin signs in through a live IdP · live: :4822 identity.db, OS_SCIM_ENABLED=true. SCIM credential: POST /auth/admin/scim/mint-credential {connectionId:"qa-c2-okta"} → 200 {credentialId zMkaLJFp…, tokenHint, expiresAt null} (bearer shown once, not recorded). Provisioned user g1: POST /auth/scim/v2/Users (bearer) → 201, SCIM id LirVz14E…, sys_user hMTj9nOD… source idp_provisioned. Org admin persona: qa.c2.second, POST /auth/organization/update-member-role {role admin} → 200; /auth/me/permissions positions [org_admin, everyone] (no platform admin). Scratch set qa_group_reader (PUT /meta/permission, objects {showcase_task {allowRead}}) → 200.

    # clause (short) verdict evidence
    A0 mapping doors platform-admin only; out-of-boundary set 400 at permissionSet pass org admin POST /auth/admin/scim/set-group-mapping {connectionId qa-c2-okta, groupDisplayName qa-readers, permissionSet qa_group_reader} → 403 {"code":"PERMISSION_DENIED","message":"Admin role required"} (anon → 401); platform admin same body with admin_full_access → 400 VALIDATION_FAILED fields[{field permissionSet, code rule_violation, "…never administrator standing; refused: permission set 'admin_full_access', which is outside the organization boundary by name…"}]; platform admin qa_group_reader → 200 {mappingId i8Hp1GC2…, created true, organizationId = the connection's org, reprojected true}
    A1 SCIM group push grants the mapped set at once with the marker pass before: g1 grants []; POST /auth/scim/v2/Groups {displayName qa-readers, members [{value LirVz14E…}]} → 201; immediately after: sys_user_permission_set {permission_set qa_group_reader, reason "scim-group-projection: provisioning domain 'qa-c2-okta'", organization_id = org} (prefix = SCIM_GROUP_PROJECTION_REASON_PREFIX 'scim-group-projection')
    A2 delete removes the marked grant, keeps a hand-made one pass (a) delete-group-mapping {mappingId} → 200 {deleted true, reprojected true}; the marked grant gone (grants []). (b) hand-made: admin POST /data/sys_user_permission_set {g1, qa_group_reader, reason "QA C2 hand grant"} → 201; re-save the mapping → 200 (grants unchanged: only the hand row — nothing written beside it); delete-group-mapping → 200; the hand-made row pGx-HcyH… remains. Step 4's literal order (hand grant while the marked one exists) is refused 409 UNIQUE_VIOLATION — see Parked
    A3 SSO sign-in grants from groups claim; delete revokes blocked(fixture) knownGaps[0]: no stock OIDC identity provider (FOLLOW-UPS §3)
    N0 delete removes a hand-made grant pass A2(b)
    N1 administrator set granted through a group pass A0 refusal; no scim-group-projection row exists with any set but qa_group_reader (filter reason like scim-group% → total 0 after delete); admin_full_access rows: only the seeded admin's (reason null)
    S1 identity-auth.idp-group-permission-set-mapping · probe beside the clauses · security finding — detail withheld, reported to PM fail —
    Probe (~3 min, platform admin): {} → 400 VALIDATION_FAILED (connectionId + permissionSet required); both connectionId and providerId → 400 rule_violation "not both"; unknown connection → 400 reference_not_found "mint its credential first"; groupDisplayName "" → 400 "Name the group by groupExternalId or by groupDisplayName"; mcp_agent_data_read → 400 boundary refusal; delete {} → 400, unknown mappingId → 404 RESOURCE_NOT_FOUND. Wording note (Parked): an unknown set name (no_such_set) is refused with the boundary sentence "…never administrator standing; refused: permission set 'no_such_set', which is outside the organization boundary…" rather than "no such set". No product finding.

    identity-auth.ai-service-account-register-and-key (rev 1, P1) — PASS

    pins: dogfood service-account-register-key.dogfood.test.ts → 10/0 (HTTP, in-process booted showcase) · plugin-auth service-account-doors.test.ts + (A4) service-account-sign-in-refusal.test.ts + last-admin-guard.agent-account.test.ts (in the 10-file run 197/0; unit) · live: :4822 identity.db. Personas via admin POST /auth/admin/create-user + change-password: owner s7PAJ4Vh…, second irEJhZ4H…, third it8vh3vW…, outsider 5230GOw8… (then POST /auth/organization/remove-member → sys_member rows 0).

    # clause (short) verdict evidence
    A0 register: anon 401, member 403, non-member owner 400; no row pass anon POST /auth/service-account/register {name qa-bot, ownerId owner} → 401 UNAUTHENTICATED "Sign in first"; second → 403 PERMISSION_DENIED "Only an administrator of this organization, or a platform administrator, can register an AI service account."; admin + ownerId outsider → 400 VALIDATION_FAILED fields[{ownerId, invalid_value}] "…is not a member of this organization…"; GET /data/sys_user?filter={name:qa-bot} → total 0 after the three (1 after the success)
    A1 201 body; agent row owned by owner, .invalid address, one plain membership pass 201 (status read on the twin register qa-bot2) {userId njDsNFBiMddRala2, name qa-bot, principalKind agent, organizationId org_mwetrsdrpt68vcwr, ownerId s7PAJ4Vh…}; GET /data/sys_user/njDs… → principal_kind agent, accountable_user_id owner, email u-ffcvpa6de77w2qwjouuc@placeholder.invalid; sys_member [{org, role member}]
    A2 create-key: other member 403, person id 404, ceiling 400 at expires_at; key row pass second → 403 PERMISSION_DENIED "Only the AI service account's accountable owner, an administrator of its organization, or a platform administrator can create a key for it."; owner with userId = owner → 404 RESOURCE_NOT_FOUND "No AI service account 's7PAJ4Vh…' exists."; owner +400 d → 400 VALIDATION_FAILED "…Maximum key lifetime is 365 days…"; 0 key rows after the refusals; owner → 201 with raw key once (K1 h82Gxyru…), admin → 201 (K2 p_zDq8sD…); rows {user_id = account, active_organization_id = account's org}, no key field
    A3 key admits as the account; key-only caller mints nothing pass K1 GET /data/sys_user → 403 PERMISSION_DENIED (admitted, then refused by object permission: the fresh account holds nothing under the agent ceiling — part-B recipe), made-up osk_ key → 401; "ran as": the key's credential_use audit row has user_id = actor = njDs… (the account), metadata credential_id = K1; after a scratch grant (qa_c2_task_reader) the same key reads showcase_task 200. K1 POST /auth/service-account/create-key → 401 UNAUTHENTICATED "Sign in first"; K1 POST /keys → 403 PERMISSION_DENIED "Creating an API key requires a signed-in session…"; account key rows still 2
    A4 never signs in / no sys_account / not admin ([test]) pass sign-in-refusal + last-admin-guard.agent-account green in the plugin-auth run; live corroboration: sys_account rows for the account 0; sign-in/email with its placeholder address → 401 INVALID_EMAIL_OR_PASSWORD
    N0 member register/mint writes anything pass counts in A0, A2
    N1 key org = caller's/body's pass active_organization_id = the account's org
    N2 raw key reappears pass GET /data/sys_api_key/{K1} body contains the raw key 0 times; store column key is a 64-char digest ≠ raw key
    Probe (~2 min, admin/anon/second): register name "" → 400 VALIDATION_FAILED at name; ownerId = the (already retired, membership-less) agent account → 400 "…is not a member of this organization"; suspend anon → 401, second → 403, a person's id → 404 "No AI service account …", {} → 400 INVALID_REQUEST "userId is required"; revoke-key unknown → 404; create-key userId "" → 400 INVALID_REQUEST. No finding.

    identity-auth.ai-service-account-lifecycle (rev 1, P1) — PASS

    pins: dogfood service-account-lifecycle.dogfood.test.ts → 5/0 (HTTP) · plugin-auth service-account-lifecycle.test.ts (in the 10-file run 197/0; unit) · live: :4822 identity.db; account njDs…, K1 (owner-minted), K2 (admin-minted); reads = GET /data/showcase_task?limit=1 with each key, after a scratch grant qa_c2_task_reader so an admitted key answers 200 (without it the agent ceiling answers 403 for an admitted key, which would not tell 200 from 403 — see Parked).

    # clause (short) verdict evidence
    A0 suspend refuses every key, revokes none; owner resume 403; admin resume restores pass reads 200/200; owner POST /auth/service-account/suspend → 200 {suspended true, reason "AI service account suspended"}; reads 401/401; sys_user banned true, ban_reason "AI service account suspended"; store revoked 0/0, reason null; owner resume → 403 PERMISSION_DENIED "Only an administrator of the AI service account's organization, or a platform administrator, can resume it."; admin resume → 200 {suspended false}; reads 200/200
    A1 revoke-key stops one; owner can't restore admin's revoke pass admin revoke-key K1 → 200 {revoked true}; reads 403 (API_KEY_REVOKED "…suspended by a platform administrator…") / 200; owner restore-key → 403 PERMISSION_DENIED "This key was revoked by an administrator (administrator_suspended), so only a platform administrator can restore it…"; admin restore → 200 {revoked false}; reads 200/200
    A2 owner leaving suspends; resume 409 until set-owner pass admin remove-member owner → 200; sys_user banned true, ban_reason "Accountable owner left the organization" (= SERVICE_ACCOUNT_OWNER_LEFT_REASON); reads 401/401; resume → 409 RESOURCE_CONFLICT "…has no accountable owner in its organization. Name a new owner (set-owner) before resuming it."; set-owner third → 200; resume → 200; banned false, accountable_user_id third; reads 200/200
    A3 retire terminal pass retire → 200 {retired true, keysRevoked 2}; K1/K2 → 403 API_KEY_REVOKED "…revoked because its owner's membership in the organization ended, and it cannot be restored…"; store revoked_reason organization_membership_ended ×2; sys_member total 0; restore-key K2 → 403 PERMISSION_DENIED; retire again → 409 RESOURCE_CONFLICT "…was retired"; create-key → 409 RESOURCE_CONFLICT; key rows still 2; a data-door PATCH {revoked:false} on K2 by the admin → 403 (restore guard rule 1)
    N0 owner resumes own suspension pass 403 (A0)
    N1 retired account key restored / new key pass 403 / 409, no new row (A3)
    Probe: see the register-and-key probe line, plus the #33075 probe below.

    #33075 headline probe (rule 8, ~6 min, on :4822 identity.db — scored nothing; doors: SCIM PATCH/DELETE /Users, REST data, MCP, the restore doors, the audit ledger)

    SCIM user d1 (bnM358o6…, provisioned through qa-c2-okta) given a local password by POST /auth/admin/set-user-password → 200 (non-cloud-managed), signed in, minted 3 keys over POST /keys: live, one self-revoked (reason null), one admin-revoked (administrator_suspended); controls: third's key, the admin's key. SCIM PATCH active:false → 200: d1 banned ("Deactivated via SCIM"), session token → 401; all 3 d1 keys revoked:1 revoked_reason scim_deactivated (both earlier reasons restamped); each key → REST GET /data/showcase_task 403 API_KEY_REVOKED "…revoked because the organization's directory deactivated its owner, and it cannot be restored, even after the owner is re-activated. Its owner creates a new key…", and MCP POST /api/v1/mcp tools/call query_records → 403 same code; third's and admin's keys 200 on both doors (untouched). Admin restore via data door PATCH {revoked:false} → 403 PERMISSION_DENIED (names the reason and the new-key road); service-account/restore-key on a person's key → 404 (not its door). Audit: one sys_audit_log update row per revoked key, object sys_api_key, actor svc:scim:qa-c2-okta, metadata {scim_connection_id}, tenant_id = org, user_id null. SCIM PATCH active:true → 200: ban lifted, every old key still revoked (REST + MCP 403); owner restore ×2 and admin restore → 403; an admin re-revoke keeps scim_deactivated; a key minted after re-activation → 200 on REST and MCP. SCIM DELETE /Users/{id} → 204: user banned again, the post-reactivation key restamped scim_deactivated → 403. No error incomplete-revocation line in the log. The commit's rule holds on every door tried. Note: the dispatch expected 401 on a revoked key; the door answers 403 API_KEY_REVOKED by design (core api-key.ts) — distinct from the 401 an unknown key gets. The security row from this probe is S1 on identity-auth.idp-group-permission-set-mapping.

    Fails

    None. No clause failed on any item. The security row sits on identity-auth.idp-group-permission-set-mapping (S1).

    Parked

    • scripts/qa/qa-fixture.mjs walled-boot boots os dev without --no-compile (its argv: dev --ui --seed-admin -p … -d …), so on a shared checkout it rewrites examples/app-showcase/dist/objectstack.json under every other runner — the RUNNER.md "boot with --no-compile" entry's hazard, inside the very command RUNNER.md and this item's knownGaps[2] point runners at. C2 booted the two walled postures by hand instead (posture env + --no-compile). Suggest the fixture pass --no-compile (or take a flag), and RUNNER.md's walled-boot entry say so.
    • no-active-org steps[8] still has the member "create a showcase_account and a showcase_project in A": a plain member is refused showcase_account create with the generic CRUD 403 (with or without an active org), and showcase_task now requires project (400 VALIDATION_FAILED without it). The operator staged account/project; a runner reading the generic 403 as "the refusal" would mis-score A1. Suggest steps name showcase_task (with a project) / showcase_private_note as the tenant-scoped write targets. Same for update/delete: PATCH/DELETE of an A task by a plain member is the CRUD 403 whatever the org state.
    • no-active-org A0 still mixes legs of different scorability in one clause (isolated producer 2, group producer 2, group producer 1); C2 scored it as three sub-rows A0a/A0b/A0c so the derived item verdict stays honest. Producer (1) remains blocked(fixture) (knownGaps[0]).
    • import-invitation-and-sso-domain-answers fixtures.requires[2] names only OS_SSO_DOMAIN_VERIFICATION; the domain doors are SSO-gated and need OS_SSO_ENABLED=true (or plugins.sso) on BOTH boots — the requires line should name it (as the dispatch also noted). sys_identity_email has no template-id column, so A1's "template id" verify is read off subject/body.
    • idp-group-permission-set-mapping steps[3] ("grant qa_group_reader to the same user by hand; then delete the mapping") cannot be driven in that order: with the projected grant present, the hand grant is refused 409 UNIQUE_VIOLATION on (user, set, org). The pin's CONTROL makes the hand grant FIRST and shows the projection writes nothing beside it. Suggest the step say: hand-grant first, then save/push, then delete. Wording note: an unknown permission-set name is refused with the boundary sentence ("…never administrator standing; refused: permission set 'no_such_set', which is outside the organization boundary…") rather than "no such permission set" — accurate-by-construction but misleading; not filed.
    • ai-service-account-lifecycle A0/A2 "both reads answer 200": under the agent ceiling a freshly registered account's admitted key answers 403 on data reads (ADR-0151 D3), so the clause's 200 needs a grant the steps do not mention. C2 granted a scratch object-read set (qa_c2_task_reader); a built-in agent ceiling set (mcp_agent_data_read) is refused as a person grant (400). Suggest fixtures.requires name the grant, or the verify read "admitted (not 401)".
    • fix(plugin-auth): a directory deactivation revokes the user's API keys, and no one restores them #33075 docs vs dispatch: a directory-revoked key answers 403 API_KEY_REVOKED on REST and MCP, not 401 — by design; the clause wording any future item gets for this rule should expect 403 with the scim_deactivated text. Checklist gap: no item asserts the SCIM-deactivation key revocation live (the headline change has unit pins only: scim-deactivation-reconcile-user, api-key-restore-guard, core api-key-lifecycle); the probe above is a ready recipe (mint credential → POST /Users → admin set-user-password → keys → PATCH active false/true → DELETE).
    • Mount note: no per-file vitest counts in multi-file package runs (non-TTY default reporter); package totals cited.
  3. objectstack-fleet commented on Nov 13, 2026

    @objectstack-fleet
    ContributorAuthor

    qa#3 · os-tesla · session_01L9qibw6yXcLi9aRRBpPT4W

    qa#3 · 18.4.0 R2 part C · C-api · group C3 — subject c419609

    Runner C3 used ports 4830–4834 (plus 4839 for a local delay server) out of 4830–4839. Only one showcase server was up at a time. Every boot was setsid, and each process group was stopped when its item finished. Every tree boot was OS_PORT=$P … objectstack dev --seed-admin -p $P -d file:/tmp/qa3b/C3/db/<DB>.db --no-compile --no-watch --log-level info, run from examples/app-showcase.

    Boots and DBs

    • :4830 durable.db: the P0 item. It was booted twice over the same file, with a cold restart in between.
    • :4831 stockgroup.db: one shared-fixture group for packaged-flow-write-door-parity, then flow-toggle-kill-switch, then flow-run-semantics-v18 (live legs).
      • The group ran in that order. Parity mutates nothing, and kill-switch restores the toggle.
      • It was then rebooted on the same file with OS_ALLOW_UNLINTED_METADATA_WRITES=1 for the e003ebb rule-8 probe only. That rule-8 probe needed a read-only write that the author-time lint flow-update-readonly-field otherwise refuses at save. No clause was scored on the relaxed boot.
    • :4832 trigger.db: trigger-status-contract. The telemetry DB was trigger.telemetry.db, and the delay server ran on 127.0.0.1:4839.
    • :4833 scratch copy /tmp/qa3b/C3/ws/examples/app-sub, DB subflow.db: packaged-flow-subflow-disable-refusal. It needed install-local, which writes into the app dir. The copy has ws/packages and ws/node_modules symlinked to the tree.
    • :4834 sla.db: approvals.sla-escalation, run under the wall-clock offset shim NODE_OPTIONS=--require /tmp/qa3b/C3/sla/clock-shim.cjs (a copy of T3's), with offset file /tmp/qa3b/C3/sla/offset. The same boot also hosted the 9df85e1 MCP rule-8 probe.
    • A scratch CLI copy /tmp/qa3b/C3/ws/examples/app-cli was used for the SLA build oracle.

    Evidence locations

    • Traces: /tmp/qa3b/C3/<group>/*.log
    • Drivers: /tmp/qa3b/C3/bin/*.mjs. Some are adapted from T2-B's and T3's recipes, with ports and paths changed.

    Pins

    • One vitest run per package at --maxWorkers=1. Dogfood ran one file per invocation. Logs are in /tmp/qa3b/C3/pins/*.log and the summary is in /tmp/qa3b/C3/pins/summary.txt.
    • service-automation: 4 files, 72/0 (engine). These are flow-cel-current-user, flow-cel-record-binding and builtin/decision-overlapping-edge-conditions.pin, plus run-dropped-fields.test.ts as e003ebb corroboration.
    • runtime: 2 files, 52/0 (plugin unit over a scripted automation service).
    • verify automation-trigger-elevated-door: 1 file, 16/0 (real kernel).
    • dogfood flow-door-elevated-start: 1 file, 27/0 (HTTP, in-process booted stack).
    • Corroboration for 9df85e1: plugin-approvals sys-approval-request.object.test.ts 18/0 (schema/unit), and dogfood approval-decide-over-mcp 8/0 (HTTP over the live MCP transport).
    • Total: 7 named pin files plus 3 corroborating files. That is 193 tests, 0 failed, with no OOM kills.

    Other checks

    • All 7 revisions match the area JSON: 10, 3, 14, 12, 4, 4, 4.
    • The tree's examples/app-showcase/.objectstack/ holds only data/, so nothing was installed into it.

    Wall-clock: about 6 min setup (10:10–10:12 reading and helpers, plus the scratch copies) and about 22 min testing (10:12–10:34 UTC, including roughly 5 min waiting on the SLA periodic tick).


    automation.packaged-flow-disable-durable (rev 10, P0) — PASS

    pins: none (manual) · live: :4830, durable.db, two boots over the same file, both --log-level info · admin
    Baseline: urgent create 7IzIxOWY… → runs 0 → 1 (run_fa9c5822… completed).

    # clause (short) verdict evidence
    A0 toggle writes the durable row; no tenant column pass live: POST /automation/showcase_urgent_task_alert/toggle {enabled:false} → 200 {name, enabled:false}. GET /data/sys_metadata_activation → total 1: row Sp9nVaxSywmrcvy-, metadata_type=flow, name=showcase_urgent_task_alert, package_id=com.example.showcase, active=false. KEY SET = [active, created_at, created_by, id, metadata_type, name, package_id, updated_at, updated_by], and 'organization_id' in row = false
    A1 FLOW_DISABLED message is the switched-off sentence pass live: POST …/trigger → 409 {code:FLOW_DISABLED, message:"This flow is turned off for this deployment. An administrator can turn it on again in its package's Automations in Studio, or copy it there under a new name."}. Exact string match true; no sys_metadata_activation, no ADR
    A2 disable survives a cold restart, trigger unbound pass live: process group stopped, port confirmed free, second boot on the same file. GET /automation/_status → {enabled:false, bound:false, status:active, triggerType:record_change}. server-1.log:1042 INFO [Automation] Activation ledger: 1 packaged flow(s) are switched off for this installation and were left unbound — 'showcase_urgent_task_alert'.
    A3 kill switch holds on the new process pass live: runs before = 1 [run_fa9c5822]. POST /data/showcase_task {title, priority:urgent, project} → 201 7TVJy8yh…. Runs after (4 s) = 1, same id
    A4 re-enable UPDATES the same row; firing resumes pass live: toggle {enabled:true} → 200. GET /data/sys_metadata_activation/Sp9nVaxSywmrcvy- → same id, active=true, created_at 10:13:07.073Z unchanged, updated_at 10:13:48.580Z; ledger total still 1. Next urgent create f_vJ8_yY… → runs 2 (new run_e0675716… completed, recordId f_vJ8_yY…)
    N0 (neg) 2xx toggle with no row pass row present right after the 200 (A0)
    N1 (neg) restart re-arms pass A2 enabled/bound false; A3 no run
    N2 (neg) re-enable deletes the row pass A4: same id still present
    N3 (neg) ledger disable answered with the bare status sentence pass A1 message is the turned-off sentence, not "Flow '…' is disabled"

    Probe (~2 min; admin, phone.demo member, anonymous; toggle and ledger data doors):

    • {enabled:"false"} → 400 VALIDATION_FAILED.
    • Unknown flow → 404 RESOURCE_NOT_FOUND; name %20 → 404 "Flow ' ' not found".
    • POST, PATCH and DELETE on /data/sys_metadata_activation → 405 OBJECT_API_METHOD_NOT_ALLOWED (allowed: get, list, aggregate, search, export). An unknown row id → 404 RECORD_NOT_FOUND.
    • Anonymous toggle → 401. Member read of the ledger → 403 PERMISSION_DENIED. Member toggle → 403 "requires the manage_metadata capability", and /_status was unchanged.

    No findings.

    automation.flow-run-semantics-v18 (rev 3, P1) — PASS

    pins:

    • service-automation flow-cel-current-user, flow-cel-record-binding, decision-overlapping-edge-conditions.pin: in 4 files, 72/0 (ENGINE)
    • runtime action-flow-elevated-door, endpoint-flow-elevated-door: 52/0 (PLUGIN UNIT over a scripted automation service)
    • verify automation-trigger-elevated-door: 16/0 (REAL KERNEL)
    • dogfood flow-door-elevated-start: 27/0 (HTTP)

    live: :4831, stockgroup.db. Scratch flows were registered at runtime by the admin through POST /automation: qa_c3_stamp (record-after-update on showcase_private_note, condition title != previous.title, update_record body = CEL current_user.id), qa_elevated (autolaunched, runAs system, create_record), and qa_c3_decision (decision with edges 1 == 1 then 2 == 2). The member qa-c3-mem-… was made with admin/create-user (mustChangePassword:true), then change-password, then a fresh sign-in.

    # clause (short) verdict evidence
    A0 permanent pins pass pass all 7 named files ≥1 test, 0 failed (package totals above; per-file layers as listed)
    A1 record-triggered run stamps the member's id pass live: the member POSTs a note (201 gkQPLJO2…) and PATCHes its title (200). Read-back body = xiehwUqn… = the member's id; updated_by = the member. qa_c3_stamp runs: 1, completed, trigger.userId = the member
    A2 member start of the elevated flow → 403, no run pass live: POST /automation/qa_elevated/trigger (and the legacy /automation/trigger/qa_elevated) → 403 PERMISSION_DENIED "This caller may not start this flow. A flow that runs on its own trigger starts there, or as a sub-flow…". qa_elevated runs 0 → 0; "qa-c3 elevated ran" rows 0 → 0. The admin gets the same 403
    A3 decision takes the first true edge only pass live: trigger → 200, summary node b skipped. GET run steps = [start:success, d:success, a:success, b:skipped]; FIRST rows 1, SECOND rows 0
    N0 no privilege escalation pass A2. Body {record:{id}} and body {runAs:'user'} → still 403; no run, no row

    Probe (~1 min):

    • An unknown flow as the member → 404, against 403 for the existing elevated flow. This is the same low existence signal as last round, and it was not filed.
    • Anonymous → 401.
    • A member's GET of runs → 403 "requires read access to 'sys_automation_run'".
    • A member's trigger of the runAs-user decision flow → 200.

    No findings.

    automation.trigger-status-contract (rev 14, P1) — PASS

    pins: none · live: :4832, trigger.db (telemetry trigger.telemetry.db), delay server 127.0.0.1:4839 · admin + contributor member qa-contributor-20261113101932@verify.test (node scripts/qa/qa-fixture.mjs contributor-member; not the trigger identity)

    Recipe qa-flow-status-doors, plus the scratch flows qa_screen_child, qa_map_parent (map → qa_screen_child) and qa_resume_inprog (screen → http GET :4839/slow?s=6 → screen):

    • step 1 POST /packages → 201
    • step 2 PUT qa_no_start_node → 422 INVALID_METADATA with rule flow-start-node-missing; GET /meta/flow/qa_no_start_node → 404
    • steps 3 and 6 → 200
    • step 4 toggle urgent alert OFF → 200
    • step 5 at A4
      | # | clause (short) | verdict | evidence |
      |---|---|---|---|
      | A0 | every trigger row, both routes | pass | live, identical at POST /automation/:name/trigger and /automation/trigger/:name. Unheld → 404 RESOURCE_NOT_FOUND "Flow 'qa_unheld_flow_xyz' not found". qa_no_start_node → 404 RESOURCE_NOT_FOUND "Flow 'qa_no_start_node' not found" (not 422, not 200). Urgent alert (off) → 409 FLOW_DISABLED. qa_input_schema_invalid → 422 FLOW_INPUT_SCHEMA_INVALID "Node 'assign' parameter 'assignments' expected type 'string' but got 'object'". qa_always_fails → 400 FLOW_FAILED "Node 'boom' failed: http: fetch failed — api.example.com: getaddrinfo ENOTFOUND". showcase_notify_owner {params:{ownerId, message}} → 200, data.success true. No refusal row answered 200 |
      | A1 | PAUSED = 200 + runId + screen | pass | live: showcase_reassign_wizard {params:{recordId, objectName}} → 200 {status:'paused', runId:run_65f6d270…, screen.nodeId:'collect'} (legacy route → run_08d04029…). run_65f6d270 later resumed 200 → completed, task.assignee 'qa-c3-assignee' |
      | A2 | cross-door parity for one engine exit | pass | live: wizard OFF → POST /actions/showcase_task/showcase_bulk_reassign → 409 FLOW_DISABLED, and the trigger door → 409 FLOW_DISABLED. showcase_inquiry_purge OFF → POST /apps/showcase/inquiries/purge → 409 FLOW_DISABLED, and the trigger door the same; closed inquiries 1 before, 1 after. Authored qa_fail_action and qa_schema_action (PUT /meta/action/…?package=…, 200): /actions → 400 FLOW_FAILED and 422 FLOW_INPUT_SCHEMA_INVALID. Both flows toggled back ON |
      | A3 | 400 carries details.errorMessage + summary | pass | live: trigger door (both routes) details.errorMessage "QA always-fails probe: the unroutable http node failed" and details.summary.nodes [start success, boom http failure]. The /actions door has the same two keys; its message is prefixed "Flow 'qa_always_fails' failed: …" |
      | A4 | resume refusals; retryable arms keep the suspension | pass | live on run_65f6d270: see "A4 resume arms" below |
      | A5 | stale suspension → 404 RUN_NOT_FOUND naming the cause | pass | live: paused qa_screen_child run_1ef69768; DELETE /automation/qa_screen_child → 200; resume → 404 RUN_NOT_FOUND "Flow 'qa_screen_child' not found for run 'run_1ef69768…'". This is the deregistration arm; the edited-node arm was not driven (Parked) |
      | A6 | SDK rejects with httpStatus + code | pass | live: @objectstack/client (packages/client/dist/index.mjs) against :4832. trigger() and execute() REJECT 404 RESOURCE_NOT_FOUND ×2 (unheld, qa_no_start_node), 409 FLOW_DISABLED, 422 FLOW_INPUT_SCHEMA_INVALID and 400 FLOW_FAILED, and RESOLVE the 200 and the paused run. resume() REJECTs 403 PERMISSION_DENIED, 400 INVALID_SCREEN_INPUT, 400 INVALID_SIGNAL, 404 RUN_NOT_FOUND and 503 STORE_UNAVAILABLE, then RESOLVES the valid resume |
      | A7 | start-less flow refused where authored | pass | live: POST /automation qa_no_start_node_save → 400 VALIDATION_FAILED "Flow 'qa_no_start_node_save' rejected: the flow's top-level nodes hold no start node (found 1 node(s), of type end)…"; GET /automation/qa_no_start_node_save → 404. Recipe PUT → 422 INVALID_METADATA, rule flow-start-node-missing; GET /meta/flow/qa_no_start_node → 404 |
      | N0 | (neg) 200 with an inner success:false | pass | none across every row and door |
      | N1 | (neg) never-dispatched exit → 400 FLOW_FAILED | pass | disabled → 409, schema → 422, unheld and no-start → 404, at every door |
      | N2 | (neg) 409/422 collapsed | pass | distinct at the trigger and /actions doors |
      | N3 | (neg) paused answered as a refusal | pass | A1 |
      | N4 | (neg) a retryable refusal consumed the suspension | pass | A4: every re-read paused, and every later valid resume 200 |
      | N5 | (neg) a door with its own table | pass | source: domains/automation.ts:3158, action-execution.ts:2859 and endpoint-executor.ts:633 each call classifyFlowRefusal, imported from flow-dispatch-status.ts (FLOW_NOT_FOUND_STATUS at :140, classifyFlowRefusal at :194) |

    A4 resume arms, live on run_65f6d270 unless noted:

    • Bodyless → 400 INVALID_SCREEN_INPUT. inputs:{} → 400 INVALID_SCREEN_INPUT.
    • {inputs:{new_assignee}, output:{$x:1}} → 400 INVALID_SIGNAL "(collect.$x)".
    • output-only → 400 INVALID_SCREEN_INPUT. $ inside inputs → 400 INVALID_SCREEN_INPUT. inputs + variables → 400 VALIDATION_FAILED.
    • Member → 403 PERMISSION_DENIED "requires being the identity that triggered the run…".
    • sys_automation_run renamed away in the telemetry DB → 503 STORE_UNAVAILABLE "Durable suspended-run store unreachable for run …".
    • The run re-read paused after each refusal. The valid resume after the rename back → 200 completed.
    • Unknown runId → 404 RUN_NOT_FOUND.
    • 409 RESUME_IN_PROGRESS: on run_787c170e, a second resume 2 s into the first → 409 "…is already being resumed". The first → 200, paused at collect2; a later resume with y → 200 completed.
    • 409 RESUME_CHILD_PENDING: on qa_map_parent run_6683b50f → 409 naming item run run_51e6c556…. The parent re-read paused; the item resume → 200, and the parent → completed.
    • Terminal 400 FLOW_FAILED: the wizard triggered without recordId, then a valid resume → 400 FLOW_FAILED (CEL "Unknown variable: recordId"). The run reads failed, and a re-resume → 404 RUN_NOT_FOUND.

    Teardown: urgent alert, wizard and inquiry purge back ON; DELETE /packages/com.objectstack.qa.automation → 200 (7 items); the telemetry table renamed back each time.

    Probe (~2 min, admin + member):

    • /automation//trigger → 404 ENDPOINT_NOT_FOUND. runId %20 → 404 RUN_NOT_FOUND.
    • Body [] → 400 VALIDATION_FAILED. params:"x" → 400 VALIDATION_FAILED. Malformed JSON → 400 INVALID_REQUEST.
    • A live wizard run resumed under another flow's name (qa_resume_inprog/runs/) → 404 RUN_NOT_FOUND, but it did NOT consume the run: the re-read was 200, and the right-name resume → 200.
    • Member resume of an unknown runId → 403, the same answer as for a real run, so there is no existence oracle. Member GET of another identity's run → 403.

    No findings.

    automation.packaged-flow-subflow-disable-refusal (rev 12, P1) — PASS

    pins: none · live: :4833, scratch copy /tmp/qa3b/C3/ws/examples/app-sub, subflow.db, admin

    # clause (short) verdict evidence
    A0 subflow-caller disable → 409 DELETE_RESTRICTED naming the caller + remedy pass live: POST /automation/showcase_notify_owner/toggle {enabled:false} → 409 DELETE_RESTRICTED "Flow 'showcase_notify_owner' cannot be disabled while 1 packaged flow still calls it as a subflow: 'showcase_task_done_notify_owner'. … (ADR-0126 §7.3). Disable the calling flow 'showcase_task_done_notify_owner' first — or leave this one armed."
    A1 MAP callers count pass live: toggle showcase_one_task_signoff {enabled:false} → 409 DELETE_RESTRICTED naming 'showcase_release_signoff' (…"Disable the calling flow 'showcase_release_signoff' first — or leave this one armed.")
    A2 refused attempt is a full no-op, child still armed pass live: GET /data/sys_metadata_activation after the refusals → total 0, []. Task created status in_review (201), then PATCH status=done (200). showcase_task_done_notify_owner run run_7e6841ec… completed, steps start:success, call_notify:success; showcase_notify_owner run run_f06803e1… completed
    A3 child enable never guarded pass live: toggle showcase_notify_owner {enabled:true} with its caller OFF → 200 {enabled:true}. After the caller came back ON, a no-op child enable → 200; no DELETE_RESTRICTED either time
    A4 the remedy is followable pass live: toggle showcase_task_done_notify_owner {enabled:false} → 200; retry of the child disable → 200 {name:'showcase_notify_owner', enabled:false}; ledger row Qdv2HAZq4A_lcMf- showcase_notify_owner active=false
    N0 (neg) 2xx on step 2/3 pass both 409 (A0/A1). The third pair also 409: showcase_closure_signoff, naming 'showcase_project_closure'
    N1 (neg) several callers, only the first named pass live on the qa-second-packaged-caller recipe: see "N1 detail" below

    N1 detail: install-local → 200 hotLoaded:true. GET /automation/qasub_second_notify_caller → status active, _provenance 'package', _packageId com.objectstack.qa.qasub. Child disable → 409 DELETE_RESTRICTED "…while 2 packaged flows still call it as a subflow: 'showcase_task_done_notify_owner', 'qasub_second_notify_caller'…". The child's ledger row was unchanged (active true, updated_at 10:21:09.491Z before and after). Teardown: DELETE /marketplace/install-local/com.objectstack.qa.qasub → 200, after which the same disable names showcase_task_done_notify_owner alone.

    Probe (~1 min, admin, toggle and create doors):

    • Name %20 → 404. enabled:"yes" → 400 VALIDATION_FAILED. Body {} → 200 enabled:true (the documented "empty body = enable" contract).
    • Map caller off, then child off → 200/200. Caller-first enable → 409 RESOURCE_CONFLICT (fix(service-automation)!: refuse re-enabling a packaged flow onto a disabled packaged subflow #20711, by design). Child then caller → 200/200.
    • A tenant (non-packaged) caller qa_c3_tenant_caller of showcase_closure_signoff was authored through POST /automation. The child disable → 409 naming only the packaged 'showcase_project_closure'; the tenant caller is not counted, as knownGaps says. The tenant caller was then deleted with 200.
    • Scratch installed-packages/ held only data after the teardown.

    No findings.

    access-security.packaged-flow-write-door-parity (rev 4, P1) — PASS

    pins: none (manual) · live: :4831, stockgroup.db, seeded admin (holds manage_metadata)
    Setup: GET /automation/showcase_urgent_task_alert captured (sha256 2e06b891b160… → /tmp/qa3b/C3/stock/capture.json). GET /meta/flow/…?layers=true → code._packageId com.example.showcase, _provenance package, lock "full", editable false, deletable false.

    # clause (short) verdict evidence
    A0 control: the /meta door refuses; definition unchanged pass live: PUT /meta/flow/showcase_urgent_task_alert (captured body, label + " (qa-c3)") → 403 NOT_OVERRIDABLE "…provided by a managed package and is sealed against in-place edits. Declare an override from 'com.example.showcase.custom' or rename it…". With ?package=com.example.showcase → 403 ITEM_LOCKED "Cannot overlay 'flow' in package 'com.example.showcase': that package is read-only, and its packaged base is locked…". GET /meta/flow hash identical before and after
    A1 parity, update door pass live: PUT /automation/showcase_urgent_task_alert, same admin, same body → 403 NOT_OVERRIDABLE with the same lock sentence as the /meta door; GET read-back hash = capture
    A2 parity, delete door pass live: DELETE /automation/showcase_urgent_task_alert → 403 NOT_OVERRIDABLE "…sealed against removal. Clone it under a new name…"; follow-up GET → 200, hash = capture
    A3 no residue, still fires pass nothing was mutated, so no restore was needed. GET hash = capture; /_status enabled:true, bound:true. POST /data/showcase_task {priority:'urgent', project} → 201, and GET …/runs 0 → 1 (run_4fe4d149… completed)
    N0 (neg) refusal from a missing capability pass every refusal quotes the package-lock sentence for the admin. For contrast, phone.demo PUT/DELETE → 403 PERMISSION_DENIED "Authoring automation flows requires the manage_metadata capability"
    N1 (neg) 2xx from PUT/DELETE /automation pass both 403 (A1/A2), each scored from its own trace

    Probe (~1 min, admin + member):

    • PUT with {} → 403 NOT_OVERRIDABLE (the lock answers before any body check). PUT with a body renaming the flow → 403 NOT_OVERRIDABLE.
    • POST /automation (the create door) with the packaged flow's own name → 403 NOT_OVERRIDABLE, so the create door cannot overwrite the base either.
    • DELETE of an unknown flow → 404.
    • The read-back still equals the capture.

    No findings.

    automation.flow-toggle-kill-switch (rev 4, P2) — PASS

    pins: none · live: :4831, stockgroup.db (after the parity item), admin

    # clause (short) verdict evidence
    A0 baseline urgent create fires once pass live: runs 1 → POST /data/showcase_task {title, priority:'urgent', status:'todo', project} → 1wcmwWK4… → runs 2; new run_1bc7f589 completed, trigger {type:'record-after-write', object:'showcase_task', recordId:'1wcmwWK4aWeZqJ2z'}
    A1 OFF is a kill switch pass live: toggle {enabled:false} → 200 {enabled:false}; urgent create CZoMfYXK… → runs still 2 (read 4 s later), and no run names CZoMfYXK…
    A2 /_status mirrors the toggle pass live: before: enabled=true, bound=true; after OFF: enabled=false, bound=false (triggerType record_change kept); after ON: enabled=true, bound=true
    A3 ON restores firing pass live: toggle {enabled:true} → 200; urgent create UNqPCBMk… → runs 3, new run_35907bea completed with recordId UNqPCBMk…; still no run for CZoMfYXK…
    N0 no run while OFF pass A1/A3: the runs list never names CZoMfYXK…
    N1 a 2xx toggle is mirrored in /_status pass A2
    N2 unknown flow → not found, no state change pass POST /automation/does_not_exist/toggle → 404 RESOURCE_NOT_FOUND "Flow 'does_not_exist' not found"; urgent alert /_status unchanged (true/true)

    Probe (~1 min, admin): OFF twice → 200 both; ON twice → 200 both. After that, one urgent create gave exactly one new run (3 → 4), so there is no double binding. A non-urgent create fired nothing (4 → 4). No findings.

    approvals.sla-escalation (rev 4, P2) — PASS

    pins: none · live: :4834, sla.db, wall-clock offset shim (/tmp/qa3b/C3/sla/clock-shim.cjs, offset file /tmp/qa3b/C3/sla/offset; the server's environ was checked for NODE_OPTIONS and QA_CLOCK_OFFSET_FILE). Offsets: 0 → +2h (restart) → +3.5h (no restart; one periodic tick) → +5h (restart). Timers untouched.

    Personas: admin (submitter, flow author), phone.demo (approver), auditor.demo (escalatee).

    Fixture: four runtime flows registered through POST /api/v1/automation, one per action, each on its own record-after-create object. Approver usr_showcase_phone_demo; escalation {enabled:true, timeoutHours:1, action, notifySubmitter:true}.

    • qa_c3_sla_reassign on showcase_field_zoo (escalateTo usr_showcase_auditor_demo)
    • qa_c3_sla_approve on showcase_announcement
    • qa_c3_sla_reject on showcase_contact
    • qa_c3_sla_notify on showcase_account

    Positive controls, because the sweep logs nothing: control1 was opened at +2h (due +3h) and must be escalated by the PERIODIC tick at +3.5h. control2 was opened at +3.5h (due +4.5h) and must be escalated by the BOOT sweep at +5h.

    # clause (short) verdict evidence
    A0 sla_due_at = created_at + timeoutHours pass live at offset 0: areq_f0ce627c created 10:23:55.011Z → due 11:23:55.011Z; areq_eaf5c8b0 56.878 → 11:23:56.878; areq_f6956f2b 58.715 → 11:23:58.715; areq_5b0c53dc 10:24:00.590 → 11:24:00.590 (diff exactly 3,600,000 ms each). The three seeded requests carry no sla_due_at
    A1 sweep escalates exactly once, no actor; a re-run adds none pass live: after the +2h restart (boot sweep, shim 12:25:09) each request has exactly one action='escalate' row. Comments: "reassign → usr_showcase_auditor_demo", "auto_approve", "auto_reject", "notify". The actor_id key is ABSENT on the /actions read. Re-read after the periodic tick at +3.5h (10:30:09 real), and again after the +5h restart: still 1 row each. Both sweeps are proven to have run by their positive controls: control1 areq_af86060c got its escalate row at shim 14:00:09 (the periodic tick), and control2 areq_41cec0d3 at shim 15:31:05 (the boot sweep)
    A2 per-variant effect pass reassign: pending_approvers ["usr_showcase_phone_demo"] → ["usr_showcase_auditor_demo"], run paused; the auditor inbox has approval.escalated "Approval escalated to you · An overdue approval on Field Zoo qa c3 sla zoo was escalated to you." with actionUrl /system/approvals?request=areq_f0ce627c…. auto_approve: status approved, run run_fac79d99 completed 12:25:09.402Z (shim). auto_reject: status rejected, run run_5bac8ca2 completed 12:25:09.551Z. notify: still pending; the phone.demo inbox has approval.sla_breached "Approval SLA breached · A decision on Account qa c3 sla account is overdue."
    A3 notifySubmitter honoured pass admin (submitter) inbox: 4× approval.sla_breached "Your approval request breached its SLA · <title>: escalation action '<reassign|auto_approve|auto_reject|notify>' was taken." (plus one per control later)
    A4 [build] strict escalation config at authoring pass build oracle on the scratch app-cli copy (flow file + index import added, then reverted). os validate with escalation {…, bogusKey:1} → exit 1: "✗ src/automation/flows/qa-c3-bad-escalation.flow.ts:2:38 · defineFlow validation failed … nodes.1.config.escalation.bogusKey: … Unrecognized key(s) on this approval escalation: bogusKey … Until this shape was closed, these were dropped silently". Alias {timeout:1} → exit 1 with 2 issues: "escalation.timeoutHours … requires" and "escalation.timeout … Did you mean timeout → timeoutHours?". Positive control: the same file with a valid escalation → "✓ Validation passed", exit 0. Runtime door too: POST /automation with bogusKey → 400 VALIDATION_FAILED at nodes.1.config.escalation.bogusKey, and GET → 404
    N0 no request past due without an escalate row pass all 6 overdue requests (4 + 2 controls) escalated on the first sweep after their deadline
    N1 no second escalate row pass A1: a periodic tick and a boot sweep, still 1 row each. Inbox counts for the old requests unchanged: admin 4 → 5 → 6 and phone 1 → 2 → 3 come only from the controls; auditor 1 → 1
    N2 unknown key not silently dropped pass A4, at both doors

    Probe (~1 min, phone.demo and auditor.demo):

    • The displaced original approver approving the reassigned request → 404 REQUEST_NOT_FOUND (read gate: no longer a participant).
    • The escalatee approving → 200. The request became approved, with no further escalate row.
    • The original approver approving the auto-approved request → 404 REQUEST_NOT_FOUND.

    No findings.


    Increment probes (rule 8) — e003ebb and 9df85e1

    These are probes, not clauses, so they score nothing.

    e003ebb (#33068) — map items and resumed subflow children name their dropped read-only fields. ~4 min, admin, :4831.

    • On a normal boot, every flow that writes the read-only showcase_contact.lead_score is refused at save: 422 INVALID_METADATA, rule flow-update-readonly-field. So the runtime drop is reachable only past the lint. The probe was therefore driven on a reboot of the same DB with OS_ALLOW_UNLINTED_METADATA_WRITES=1, the save-door relaxation L3 also used.
    • Control: a direct update_record of {lead_score:99, title} → 200 droppedFields:[{object:'showcase_contact', fields:['lead_score'], reason:'readonly'}], and the stored lead_score stays null.
    • No-drop control (title only) → no droppedFields member.
    • Synchronous map over 2 items, each item's child writing lead_score → 200, acted 2, and the PARENT's answer names ONE coalesced event [{showcase_contact, [lead_score], readonly}]. A map whose child drops nothing → member absent; an empty list → absent.
    • Paused subflow child resumed via the PARENT's run id: the parent's answer names the child's drop.
    • The same child resumed via its OWN run id: that answer names the drop, and the parent run (read back over GET …/runs/:id) is completed with steps[sub].droppedFields = [{showcase_contact, [lead_score], readonly}]. So creditChildRun credited the awaiting step on the up-bubble path. The parent's own answer is not returned on this path, so only the step is observable over HTTP.
    • Map over a pausing item: the parent-id resume → 409 RESUME_CHILD_PENDING; the item resume → 200 naming the drop; the parent → completed.
    • A map item over an unknown record id → 404 RECORD_NOT_FOUND with no run started. This is the flow-start admission's row read in automation.ts (the same answer as the direct flow), by design.

    No findings. The failed-child and cancelled-map-item gaps the commit documents were not probed.

    9df85e1 (#33076) — submitter's agent recall/resubmit over MCP. ~4 min, :4834 at offset 0, live MCP transport (POST /api/v1/mcp tools/call run_action).

    • Fixture: a runtime flow qa_c3_mcp_appr on showcase_private_note with approver phone.demo and an approval_revise window. Submitter: auditor.demo, who creates the note. Stranger: a fresh admin-created member past its password change.
    • list_actions (no arguments) for both the submitter and the approver lists exactly six actions on sys_approval_request: approve, reassign, recall, reject, resubmit and send_back, each requiresConfirmation. Remind, request-info and comment are absent.
    • Recall on a pending request:
      • The stranger → isError REQUEST_NOT_FOUND (404); the request stays pending with no recall row.
      • The approver → FORBIDDEN "Only the person who submitted this approval request can recall it…".
      • The submitter unconfirmed → ACTION_CONFIRMATION_REQUIRED; confirmed → ok, status recalled, one row recall:usr_showcase_auditor_demo.
      • A second recall → INVALID_STATE 409; a resubmit of the recalled request → INVALID_STATE "resubmit applies to returned requests".
    • Resubmit:
      • Resubmit while pending → INVALID_STATE.
      • The approver's send_back (confirmed) → returned, and the submitter edits the record (200).
      • The stranger's resubmit and recall → REQUEST_NOT_FOUND. The approver's resubmit → FORBIDDEN "only the submitter may resubmit". The admin's resubmit → FORBIDDEN (resubmit has no override arm, as documented).
      • The submitter unconfirmed → ACTION_CONFIRMATION_REQUIRED; confirmed → ok. Round 2 opened as a new pending request areq_1e6c5bc2. The original reads returned with rows submit, revise:phone, resubmit:auditor.
      • The status stayed returned after each refusal.
    • The admin's recall of a pending request → ok, recorded as the admin's: the documented override arm.
    • Boundaries: an unknown id → REQUEST_NOT_FOUND; an empty recordId → VALIDATION_ERROR naming the missing recordId; a stranger's REST POST /approvals/requests/:id/recall → 404 REQUEST_NOT_FOUND, the same as over MCP.

    No findings. One wording note in Parked.


    Fails

    None. No clause failed, and no product defect was found beside the clauses.

    Parked

    • trigger-status-contract A5: only the deregistration arm of the stale-suspension clause was driven. The edited-paused-node arm was not; the clause's verify reads "or", so the clause scores on one arm. This is the same as part B.
    • content/docs/ai/connect-mcp.mdx (9df85e1) says that for recall and resubmit "anyone else is refused (FORBIDDEN) and the request does not move". Live, a caller who cannot read the request (not submitter, approver or admin) gets REQUEST_NOT_FOUND (404) from the read gate first, over MCP and REST alike. Only a participant who is not the submitter (the approver) sees FORBIDDEN. The behaviour is the safer one (no existence signal). The doc sentence is imprecise rather than wrong for the participant case. A docs-accuracy note for the anchor; nothing to extract.
    • e003ebb reachability: the read-only drop this commit rolls up is unreachable through any stock save door, because flow-update-readonly-field refuses the write at author time. It reaches a live run only past the lint (OS_ALLOW_UNLINTED_METADATA_WRITES=1, a flow stored before the rule, or a host executor). The commit's pins (run-dropped-fields.test.ts, 18 tests in the 72/0 run) are engine-level. A future item that scores droppedFields over HTTP needs that fixture named.
    • flow-run-semantics-v18 steps[1] names qa_elevated with no recipe body. This run authored it the same way L3 did (autolaunched, runAs system, create_record).
    • Fixture reuse: T2-B's trigger/sdk/subflow drivers and T3's shim were reused with ports and paths changed. They are recipes only; every verdict above comes from today's boots at c419609.
  4. objectstack-fleet commented on Nov 13, 2026

    @objectstack-fleet
    ContributorAuthor

    qa#3 · os-tesla · session_01L9qibw6yXcLi9aRRBpPT4W

    qa#3 · 18.4.0 R2 part C · C-api · group C4 — subject c419609

    Runner C4, sequential (one server of mine up at a time after a helper fix noted under the stdio item). Ports 4840–4847 (stdio provisioning os dev 4840 + os start stdio legs 4841 · OAuth boots 4842 · run-action + verb-gate shared boot 4843 · route ledger 4844 · named import 4845 · declarative-boot scratch 4846 · degraded-recovery copies 4847). One file DB per item under /tmp/qa3b/C4/db/ (stdio, oauth, apigrp [shared-fixture group: ai.mcp-run-action-exposure-gate + api-backend.api-methods-verb-gate, disjoint fixtures], ledger, import, conn, conn-d, conn-broken, conn-cfgfault). Tree boots: the brief's os dev --seed-admin --no-compile --no-watch line from examples/app-showcase (helper /tmp/qa3b/C4/bin/boot.sh); stdio legs on os start from the same dir with --home /tmp/qa3b/C4/stdio/home. Scratch copies under /tmp/qa3b/C4/ws/examples/ (ws/packages + ws/node_modules symlinked): app-plant, app-plant4, app-write, app-write3 (migrate CLI only), app-conn (connector cases, compiled under OS_PORT=4846); degraded-recovery used qa-fixture copies /tmp/qa3b/C4/{brk,cfg} (torn down). Pins (Tier 1, one vitest run per package, --maxWorkers=1, dogfood alone): packages/mcp 2 files 27/0 (plugin.test 18 + plugin-execution-context 9) · packages/plugins/plugin-auth 2 files 114/0 (auth-manager.mcp-oauth 88 + mcp-oauth-plaintext-notice 26) · packages/spec 2 files 47/0 (api-derivation 32 + connector-provider 15) · packages/services/service-automation 1 file 110/0 · packages/qa/dogfood showcase-declarative-mcp 2/0 — total 9 files, 300 passed / 0 failed; logs /tmp/qa3b/C4/pins/*.log (summary.txt). All nine revisions match the area JSON at c419609 (9, 6, 7, 4, 4, 7, 6, 7, 1). Hygiene at the end: ports 4840–4849 free, no process of mine left, git status --porcelain empty, examples/app-showcase/.objectstack/ holds only data/ (no installed-packages). Wall-clock: ~6 min setup (brief, items, recipes, helpers; 10:09–10:13 plus helper writing interleaved) · ~34 min testing (pins 10:11:30–10:13:05, live 10:13–10:47 UTC).


    ai.mcp-stdio-fail-closed (rev 9, P0) — PASS

    pins: packages/mcp src/tests/plugin.test.ts → 18/0, src/tests/plugin-execution-context.test.ts → 9/0 (PLUGIN UNIT, mocked ctx — corroborating only, scores nothing; log /tmp/qa3b/C4/pins/mcp.log, per-file counts mcp-verbose.log) · live: provisioning on os dev :4840, every stdio leg on os start -p 4841 spawned by a scripted stdio client (/tmp/qa3b/C4/bin/stdio.mjs, stdout = JSON-RPC only; --home /tmp/qa3b/C4/stdio/home, fixed --auth-secret), db file:/tmp/qa3b/C4/db/stdio.db. Personas: contributor-bound member qa-c4-contrib@verify.test (userId jtZh5nF0…, qa-fixture.mjs contributor-member, its subset proof passed: 0 seeded invoices, INV-1003 not served), client_liaison member qa-c4-liaison@verify.test (same fixture, its sys_user_position row PATCHed to showcase_client_liaison; REST read of showcase_project then 20 fields, no budget/spent/budget_remaining), admin. Three osk_ keys via POST /api/v1/keys (201). Logs /tmp/qa3b/C4/stdio/logs/*.{out,err,json}. ⚠ Driving note: my boot helper's pid capture was wrong at first, so the :4840 os dev provisioning server stayed up (same DB file) through the stdio legs; every MCP/REST leg below was served by the :4841 os start process (revoke PATCH included), so no verdict rests on the :4840 process — recorded for honesty, helper fixed before the next item.

    # clause (short) verdict evidence
    A0 enabled + keyless refuses to serve, names var + remedy pass live os start OS_MCP_STDIO_ENABLED=true, key unset: exit 1 at 10 s, 0 stdout lines, /health 000; stderr "[MCP] The stdio transport is enabled (OS_MCP_STDIO_ENABLED / autoStart) but OS_MCP_STDIO_API_KEY is not set. stdio must run under a real identity — mint an API key on the Connect an AI Assistant page … or POST /api/v1/keys, and set OS_MCP_STDIO_API_KEY=osk_.... Refusing to start an unscoped stdio server (ADR-0101)." then "✗ Plugin com.objectstack.mcp failed to start - rollback complete" (pin plugin.test.ts corroborates the var name at plugin unit)
    A1 unknown key + revoked key refused up front pass live: osk_unknown → exit 1, 0 stdout, /health 000, "[MCP] OS_MCP_STDIO_API_KEY did not resolve to a valid identity (unknown / revoked / expired / owner-less). Refusing to start stdio (ADR-0101)."; the key revoked in A6 → exit 1, 0 stdout, /health 000, "[MCP] OS_MCP_STDIO_API_KEY is refused (API_KEY_REVOKED): This API key (osk_q1xHolqG…) was suspended by a platform administrator. … Refusing to start stdio (ADR-0101)."
    A2 valid key logs principal-bound line with userId pass live --log-level info: "INFO [MCP] stdio transport principal-bound to OS_MCP_STDIO_API_KEY identity jtZh5nF0gUZgCEW4tU3ccwDsNjJCP2XA (RLS/FLS/tenant applied)" = contributor userId; liaison boot kW7DQE7o… = liaison userId; admin boot gkYHZap3… = admin
    A3 member MCP reads == member REST (RLS rows hidden, FLS masked); admin full pass RLS (contributor key): MCP query_records showcase_invoice limit 50 → 1 row [QA-20261113101419] (own), 24 fields; REST GET /data/showcase_invoice?$top=50 same key → 200 n=1 same name, identical 24-field set; get_record INV-1003 (HYD42rTyfi6RyjRK, other owner) → MCP isError Record "HYD42rTyfi6RyjRK" not found in "showcase_invoice", REST 404 RECORD_NOT_FOUND. Admin key: MCP 14 rows incl INV-1003, get_record INV-1003 served; REST 14 rows + 200. FLS (liaison key): showcase_project MCP 5 rows / 20 fields without budget, spent, budget_remaining; REST identical 5 names and identical 20-field list; admin MCP and REST 23 fields incl the three
    A4 member MCP aggregate == member REST aggregate, < admin pass contributor MCP aggregate_records {count n, groupBy status} → rows [{draft,1}], totalGroups 1; contributor REST POST /data/showcase_invoice/query same body → [{draft,1}]; admin REST → draft 4 / paid 5 / sent 5; admin MCP (stdio, admin key) → the same 4/5/5. aggregate_records present on stdio tools/list (12 tools) — knownGaps[0] does not apply
    A5 aggregate over FLS-unreadable field fails closed naming the field pass liaison MCP: sum(budget) → isError {error:{code PERMISSION_DENIED, status 403, message "[Security] Field read denied: not permitted to aggregate [budget] on 'showcase_project'"}}, no number; count_distinct(spent) → same naming [spent]; count groupBy [budget] → same naming [budget]. Controls: count by status → 4 groups (active 2, completed 1, on_hold 1, planned 1); count(*) 5; count(name) 5. REST liaison sum(budget) → 403 same message; admin REST sum(budget) → 1090000
    A6 revocation bites next call, API_KEY_REVOKED/403, no rows pass same stdio session: pre-revoke query_records → 1 row; admin SIGNED-IN session (sign-in on :4841) PATCH /api/v1/data/sys_api_key/Ho80J3xYIZDeI3pO {"revoked":true} → 200; next query_records → result.isError true, content parses to {error:{code:"API_KEY_REVOKED", status:403, message}}, no records; get_record of the member's OWN invoice → the same coded refusal
    A7 stdio disabled ⇒ no key demanded, info line pass os start with neither var: --log-level info boot → /health 200 at 10 s, 1× "INFO [MCP] Transport not auto-started (HTTP is served per-request at /api/v1/mcp regardless). Set OS_MCP_STDIO_ENABLED=true or autoStart …", 0 mentions of OS_MCP_STDIO_API_KEY, 0 stdout lines; default warn boot → /health 200, 0 such lines
    N0 invalid/revoked key never serves anonymously pass the three refusal boots (A0, A1×2) exit 1 with 0 stdout lines and HTTP never up
    N1 MCP returns rows REST hides pass A3 sets identical per principal
    N2 MCP aggregate equals admin total pass member 1 (draft) vs admin 14 (4/5/5)
    N3 masked aggregate returns a number pass A5 refusals carry no rows
    N4 post-revoke serves a row / lacks the code pass A6 coded API_KEY_REVOKED/403, no rows
    Probe (~2 min, refusal boots): OS_MCP_STDIO_API_KEY="" (empty) with stdio on → the keyless "is not set" refusal, exit 1, 0 stdout; the admin's better-auth SESSION token as the key → "did not resolve to a valid identity", exit 1, 0 stdout (no session-token side door). Also observed again: stdio get_record on an RLS-hidden row answers an UNCODED isError text, while REST answers RECORD_NOT_FOUND 404 — the prior round's open note (ee9ad83 L2, ai.mcp-coded-refusals), not re-drafted. No new findings.

    ai.mcp-oauth-private-host-transport (rev 6) — PASS

    pins: packages/plugins/plugin-auth src/auth-manager.mcp-oauth.test.ts → 88/0, src/mcp-oauth-plaintext-notice.test.ts → 26/0 (PLUGIN UNIT — corroborate only, score nothing, rule 6; logs /tmp/qa3b/C4/pins/plugin-auth.log, auth-verbose.log) · live: port 4842, os dev --no-compile boots differing only in OS_AUTH_URL, each log captured from line 1 (/tmp/qa3b/C4/oauth/{a,b,b2,c,cw,https,p172,pfd,pfec}.{log,trace}), db /tmp/qa3b/C4/db/oauth.db. Address NOT held: the host holds 127.0.0.1 only; 10.0.0.5 / 192.0.2.2 are configured literals (the item permits it); requests went to localhost:4842 and were scored on the ADVERTISED origin. steps[3] second-machine client: blocked(fixture) — no second machine; it scores no clause.

    # clause (short) verdict evidence
    A0 (a) private plain-HTTP serves OAuth; own origin; 401 pointer pass OS_AUTH_URL=http://10.0.0.5:4842: GET /.well-known/oauth-protected-resource and …/oauth-protected-resource/api/v1/mcp → 200 {resource "http://10.0.0.5:4842/api/v1/mcp", authorization_servers ["http://10.0.0.5:4842/api/v1/auth"], scopes_supported [data:read,data:write,actions:execute,metadata:write,offline_access], bearer_methods_supported [header], resource_name "ObjectStack MCP"}; anon POST /api/v1/mcp → 401 UNAUTHENTICATED "a valid OAuth access token or API key is required", WWW-Authenticate: Bearer realm="ObjectStack MCP", resource_metadata="http://10.0.0.5:4842/.well-known/oauth-protected-resource" — never localhost
    A1 (b) public plain-HTTP refused, fail-closed; log says why pass OS_AUTH_URL=http://192.0.2.2:4842: both PRM paths 404 ENDPOINT_NOT_FOUND; anon /mcp 401 "Unauthorized: a valid API key is required", no WWW-Authenticate; log 1× "WARN OAuth discovery is served over PUBLIC plain HTTP: … (… issuer http://192.0.2.2:4842/api/v1/auth) … The MCP OAuth track is DISABLED on this deployment …" + 1× "WARN MCP server is enabled but the OAuth track is NOT live (base URL fails the OAuth 2.1 transport rule — https is required on a PUBLIC host …)"; accepted-transport line 0×
    A2 exactly one plain-HTTP line per plain boot, right level, issuer named; none on https pass OAuth is served UNENCRYPTED: (a) 1 at WARN naming http://10.0.0.5:4842/api/v1/auth; (c) --log-level info: 1 at INFO naming http://localhost:4842/api/v1/auth (641 INFO lines = positive control); default warn-level loopback boot (cw): 0; (b) 0; https 0. OAuth discovery is served over PUBLIC plain HTTP: (b) 1, (a) 0, (c) 0, https 0. 「OAuth 未加密」 0 on every boot
    A3 (c) loopback unchanged, same metadata shape pass OS_AUTH_URL=http://localhost:4842: both PRM paths 200 with keys/values identical to (a), http://localhost:4842 substituted; 401 resource_metadata=http://localhost:4842/…; AS issuer http://localhost:4842/api/v1/auth (consistent)
    A4 OS_ALLOW_INSECURE_OAUTH_HTTP=true moves nothing pass (b) re-run with it exported (b2): identical 404s, the same two WARN lines 1× each, 0 accepted-transport, API-key 401; git grep -l OS_ALLOW_INSECURE_OAUTH_HTTP → only ai.json, plugin-auth CHANGELOG.md and the two test files — read by no source
    N0 200 on boot (b) pass 404 ×2 (A1)
    N1 scoring (a) from localhost only pass address not held (recorded); the ADVERTISED origin asserted is http://10.0.0.5:4842
    N2 conflating the three lines pass counted separately (A2)
    N3 intranet hostname as the private case pass not used
    https re-boot: OS_AUTH_URL=https://10.0.0.5:4842 → PRM 200 with the https origin, 0 of either plain-HTTP line, 0 NOT-live.
    Probe (~3 min, variants): http://172.32.0.1 (RFC 1918 edge) → refused like public (404 ×2, PUBLIC 1, NOT-live 1); http://[fd00::5] (ULA) → served (200, UNENCRYPTED 1 WARN); http://[fec0::5] (deprecated site-local) → refused (404, PUBLIC 1) — all as the variants list says. Re-measured known finding card-B-api-2 (not re-drafted): still reproduces at c419609 — on boot (a) GET /.well-known/oauth-authorization-server/api/v1/auth answers issuer: "https://10.0.0.5:4842/api/v1/auth" while the PRM's authorization_servers and every AS endpoint are http://; loopback (c) is http throughout. Same on public boot (b): the AS metadata issuer is https://192.0.2.2:4842/… while its PUBLIC warning names the http issuer.

    ai.mcp-run-action-exposure-gate (rev 7) — PASS

    pins: none · live: port 4843, db file:/tmp/qa3b/C4/db/apigrp.db (shared-fixture group with api-backend.api-methods-verb-gate — one boot, disjoint fixtures) · MCP Streamable HTTP POST /api/v1/mcp (initialize → tools/call); admin osk_ key and Mei Phone osk_ key minted via POST /api/v1/keys (201); admin session token from sign-in; qa_ai_gated staged per fixtures.requires[3] (PUT /meta/action/qa_ai_gated → 200 seq 1), DELETEd in teardown; task LYbbLBIe25mXidzz (first todo task: status todo, progress 0). Traces /tmp/qa3b/C4/apigrp/list-*.txt, server.log

    # clause (short) verdict evidence
    A0 undeclared action refused with the exact prescription; record untouched pass run_action {actionName:showcase_mark_done, objectName:showcase_task, recordId} and the same without objectName → isError {code VALIDATION_ERROR, status 400, message "Action 'showcase_mark_done' on 'showcase_task' is not exposed to AI — the app author must opt it in with ai: { exposed: true, description: … }"} (exact); REST re-read before/after: status todo, progress 0, done false, updated_at 10:34:29.098Z unchanged
    A1 list_actions = exposed ∧ permitted pass admin osk_ list 47 entries: showcase_portfolio_snapshot present, qa_ai_gated present, showcase_mark_done absent; Mei osk_ list 35: showcase_portfolio_snapshot present, qa_ai_gated ABSENT, showcase_mark_done absent (the staged gate discriminates the permitted half); list_actions {objectName} → isError "MCP error -32602: Input validation error: Invalid arguments for tool list_actions: Unrecognized key(s) on this list_actions call: objectName." (counts 47/35 vs 45/33 at 8e3d4a5 — more sys_* exposed entries on this tip; not a clause concern)
    A2 exposed action runs; counts reconcile pass run_action showcase_portfolio_snapshot → {ok:true, action, objectName:"global", result:{ok:true, scope:"global", accounts:14, projects:5, invoices:12}}; REST totals showcase_account 14, showcase_project 5, showcase_invoice 12
    A3 trusted-elevation audit line (log) pass server.log: [action-audit] MCP run_action 'showcase_portfolio_snapshot' on 'global' — body executes TRUSTED (system-elevated context, RLS/FLS-bypassing) for user 'yanpiqSTHcbseO0KWNi99TFzXF2XDfpf' = admin id from GET /auth/get-session
    A4 sys_* action only on its own ai.exposed; both halves pass REFUSED: create_api_key absent from all three lists (admin osk_, Mei osk_, admin session); with objectName → Action "create_api_key" on system object "sys_api_key" is not exposed via MCP: a system object's action is reachable only when its author exposed it to AI (ai.exposed) and list_actions lists it for you (exact); without → 400 VALIDATION_ERROR "Action 'create_api_key' is on a system object ('sys_api_key') and is not exposed via MCP — a system object's action is reachable here only when its author declares ai: { exposed: true, description: … } on it" (exact); admin sys_api_key rows 2 → 2, qa-must-not-exist 0 → 0. EXPOSED: revoke_api_key listed on sys_api_key aiExposed:true requiresConfirmation:true; probe key ekQdomSTlvGtlWqf (POST /keys 201); admin SESSION bearer run_action {revoke_api_key, sys_api_key, recordId, confirm:true} → ok:true, result.record.revoked true; GET /data/sys_api_key/ekQd… → revoked true
    N0 (neg) refusal half-executed pass A0 record unchanged; key count unchanged; Mei run_action qa_ai_gated → 403 PERMISSION_DENIED "Action 'qa_ai_gated' on 'showcase_task' requires capability [manage_metadata] — caller is missing [manage_metadata]", task progress still 0
    N1 (neg) list/run disagree pass under osk_ revoke_api_key is listed WITH unavailable {code PERMISSION_DENIED, reason "…Changing an API key requires a signed-in session…"} and run_action answers that same 403 sentence (second probe key stays revoked:false); under the session bearer it is listed without unavailable and runs; qa_ai_gated listed for admin / absent + refused for Mei; showcase_mark_done absent and refused
    Probe (~2 min, admin osk_ + anonymous): the prior round's card-api-4 still reproduces at c419609 (re-measured, not re-drafted) — the deployment-authored qa_ai_gated on packaged showcase_task is listed TWICE by admin list_actions (one row namespace "showcase", one with none) and refusedFaces[0] = face "showcase_task-qa_ai_gated" "derived for 2 actions: … of namespace "showcase"; … authored in this environment…" (both admin osk_ and admin session lists). Boundaries: actionName "" → "actionName is required"; unknown → 404 RESOURCE_NOT_FOUND "Action 'qa_no_such_action' not found"; showcase_portfolio_snapshot with objectName sys_api_key → the system-object refusal (no cross-object smuggling); anonymous POST /mcp → 401 with resource_metadata. No new findings.

    api-backend.api-methods-verb-gate (rev 4) — PASS

    pins: packages/spec src/data/api-derivation.test.ts → 32/0 incl. 'a non-array apiMethods resolves to deny-all, not unrestricted' (SPEC UNIT — the layer the clause names for that one arm; run together with connector-provider.test.ts, 2 files 47/0, log /tmp/qa3b/C4/pins/spec.log + spec-verbose.log) · live: port 4843, db file:/tmp/qa3b/C4/db/apigrp.db (shared boot with ai.mcp-run-action-exposure-gate) · admin, Mei Phone, Ada Auditor (session tokens), anonymous · scratch objects via PUT /api/v1/meta/object/ {sharingModel:'public_read_write', fields.name, enable.apiMethods} (200), all four DELETEd in teardown (200). Traces /tmp/qa3b/C4/apigrp/vg-*.txt
    Local closure (built @objectstack/spec/data effectiveOperationsArray∘resolveEffectiveApiMethods): ['get','list','create'] → ["get","list","create","aggregate","search","import","export"]; +bulk → ["get","list","create","bulk","aggregate","search","import","export"]; [] → []; undefined → 13 ops incl. restore, purge; ApiMethod.options = 6 (get,list,create,update,delete,bulk) = enumSource.expect.

    # clause (short) verdict evidence
    A0 404/405 fork, codes ledgered pass [system objects] admin GET list / GET :id / POST /data/sys_oauth_consent → 404 OBJECT_API_DISABLED "Object 'sys_oauth_consent' is not exposed via the API"; PATCH /data/sys_session/0HUmd1Rg… → 405 OBJECT_API_METHOD_NOT_ALLOWED "API operation 'update' is not allowed on object 'sys_session'" allowed ["get","list","aggregate","export"]; GET /data/sys_device_code → 405 'list' allowed ["get"]; both codes in error-code-ledger.zod.ts:260-261
    A1 405 allowed[] = effective closure, in order pass [scratch qa_verb_gate_probe ['get','list','create']] PATCH and DELETE → 405 allowed ["get","list","create","aggregate","search","import","export"] = the local closure element-for-element, not the raw 3. (Clause prose "restore/purge absent even for an unrestricted object" is stale — see Parked; the scored verify holds)
    A2 three-state: undefined / [] / subset (+ non-array) pass undefined: stock showcase_task GET :id / list / PATCH / DELETE (own scratch task) → 200/200/200/200; []: qa_verb_empty list + POST → 405 allowed []; stripped-to-empty: qa_verb_legacy_upsert all 5 verbs → 405 allowed []; subset: A1; non-array: PUT enable.apiMethods:"get" → 422 INVALID_METADATA "Invalid input: expected array, received string" (not live-reachable, as the clause says) + the spec-unit pin above
    A3 bulk∧child + writeMode-precise import pass [scratch] createMany (bare array) without bulk → 405 'bulk'; re-authored +bulk → createMany 201 (cm1, cm2); updateMany → 405 'update'; deleteMany → 405 'delete'; per-object /batch update → 405 'update', create → 200; import {format:'json',rows,writeMode:'insert'} → 200 created 1; writeMode 'update' + matchFields → 405 'update'; 'upsert' + matchFields → 405 'update'; the parser decoy {writeMode:'update',records} → 400 INVALID_REQUEST "writeMode "update" requires a non-empty matchFields[]" (not the gate, as the step warns)
    A4 legacy strip-and-WARN, cliff sentence, runtime matches (log) pass PUT ['upsert'] → 200; log "[spec] enable.apiMethods declares retired legacy value(s) [upsert] … • upsert: declare ['create','update'] — upsert derives from create ∧ update / ⚠ After stripping, this whitelist is EMPTY — [] means DENY-ALL (fully closed API)…" + "[Registry] Object "qa_verb_legacy_upsert" declares retired legacy apiMethods value(s) [upsert] … ⚠ Ignoring them leaves NO primitives — this object's API resolves to deny-all (fully closed)…"; runtime 5 verbs 405 allowed []. ['get','history'] → 200; "• history: declare ['get'] with enable.trackHistory: true — history derives from get ∧ trackHistory" (no cliff) + its [Registry] line; runtime allowed ["get"], GET /:id passes the gate (404 RECORD_NOT_FOUND), list/create/update/delete 405
    A5 object gate before caller authz; 401 floor; granted verbs serve pass [system object] Mei and Ada PATCH sys_session/0HUmd1Rg… → 405 bodies byte-identical to the admin's (3 identical bodies, never 403); anonymous PATCH + list → 401 UNAUTHENTICATED; admin GET :id + list 200; Mei list 200 (her own session)
    A6 cross-object batch gated per-op before txn pass POST /api/v1/batch {operations:[create showcase_task QA-VG-BATCH-SIB, update sys_session]} → 405 "API operation 'bulk' is not allowed on object 'sys_session'"; title QA-VG-BATCH-SIB → 0 rows; control batch with only the create → 200 and reads back (1 row); sys_session ip_address still 127.0.0.1
    N0 (neg) write-then-refuse pass after every 405 the scratch list holds only the rows the passing calls wrote (cm1, cm2, qa-import-1, r1; r1 never renamed); batch sibling absent; sys_session row unchanged
    N1 (neg) raw echo / restore,purge pass no raw whitelist echo; restore/purge appear in no 405 above
    N2 (neg) internal write filed as bypass pass only REST doors driven; nothing filed
    N3 (neg) legacy value hard-rejected pass both legacy PUTs 200
    Probe (~2 min, admin + Mei): unknown object → 404 OBJECT_NOT_FOUND; PATCH sys_session/nonexistent → 405 (gate before existence: no existence oracle); Mei POST on deny-all qa_verb_empty → the same 405 (gate before her authz); Mei createMany on the bulk-granted scratch → 403 PERMISSION_DENIED (gate passed, caller authz answers — correct order); Mei GET sys_oauth_consent → 404 OBJECT_API_DISABLED. No findings.

    api-backend.route-ledger-live-parity (rev 4, P2) — PASS

    pins: none (N0 forbids unit evidence) · live: :4844 stock showcase (os dev, development posture — /api/v1/discovery environment "development"), db file:/tmp/qa3b/C4/db/ledger.db; personas admin, phone.demo (no manage_platform_settings), anonymous. Raw table /tmp/qa3b/C4/ledger/table.txt (68 rows), driver /tmp/qa3b/C4/ledger/run.sh. Ledger universe at c419609: find packages -name '*route-ledger.ts' = 16 files (/tmp/qa3b/C4/ledgers.txt); REST_ROUTE_LEDGER families = 20.

    # clause (short) verdict evidence
    A0 every sampled REST ledger route ≠ routing 404 pass one+ route per family of the 20 REST families: discovery GET /api/v1 and /discovery 200; openapi 200; metadata GET /meta/object/showcase_task 200, /meta/types 200; ui GET /ui/view/showcase_task/list 200; crud GET /data/showcase_task 200, GET …/:id 200; data-actions GET …/:id/field-access 200, POST …/:id/clone 201; recycle-bin GET /data/showcase_task/recycle-bin 200; batch POST /batch {operations:[]} 200 {"results":[]}, POST /data/showcase_task/batch 400 VALIDATION_FAILED (handler); forms GET /forms/no_such_form 404 FORM_NOT_FOUND (handler code); search GET /search?q=qa 200; security GET /security/suggested-bindings 200; security-explain GET and POST /security/explain 200 allowed:true; record-shares GET /data/showcase_task/:id/shares 200 {"data":[]}; sharing-rules GET /sharing/rules 200; user-work GET /security/users//assigned-work 200; approvals GET /approvals/requests 200; email POST /email/send {} 400 VALIDATION_FAILED "subject is required"; analytics POST /analytics/dataset/query {} 400 VALIDATION_FAILED; packages GET /packages 200, POST /packages/publish {} 400 MISSING_REQUIRED_FIELD; external-datasource GET /datasources/showcase_external/external/tables 200 (customers, orders)
    A1 the 404 control is a control pass GET /api/v1/definitely-not-a-route → 404 {"success":false,"error":{"code":"ENDPOINT_NOT_FOUND","message":"Not found"}}; GET /api/v1/reports → the identical body (retired family); every handler 404 in the sweep carries its own code (FORM_NOT_FOUND, FILE_NOT_FOUND, RECORD_NOT_FOUND, NOT_FOUND "Object not found"), none byte-equal to the control
    A2 openapi.json declared envelope pass GET /api/v1/openapi.json → 200 application/json {"openapi":"3.1.0","info":{"title":"ObjectStack REST API","version":"18.3.0",…
    A3 discovery bits agree with live behaviour pass capabilities: search true → /search 200; transactionalBatch true → POST /batch 200; export true → GET /data/showcase_task/export answers the handler's 403 EXPORT_NOT_PERMITTED (per-object permission, not a missing route); analytics true → handlers answer 400 on empty bodies; notifications true → 200; i18n true → 200; ai false → GET /api/v1/ai/conversations 501 NOT_IMPLEMENTED "Provided by @objectstack/service-ai in ObjectOS Cloud/Enterprise…" (coherent refusal)
    A4 direct-mount rows sampled pass POST /api/v1/packages/publish {} → 400 MISSING_REQUIRED_FIELD; GET /api/v1/datasources/showcase_external/external/tables → 200
    A5 dispatcher ledger families answer pass GET /share-links 200 {"success":true,"data":[]}; POST /keys {name} 201; GET /notifications 200; GET /security/suggested-bindings 200; GET /i18n/locales 200 (en, zh-CN); POST /analytics/query {} 400 VALIDATION_FAILED "Invalid AnalyticsQuery body: cube: …" (capability on)
    A6 auth + storage + i18n service ledgers pass AUTH_ROUTE_LEDGER GET /api/v1/auth/get-session 200; storage GET /storage/files/nope/url 404 FILE_NOT_FOUND (handler), POST /storage/upload/presigned {} 400 INVALID_REQUEST "filename, mimeType, and size are required"; i18n service GET /i18n/translations/en 200
    A7 formerly-unledgered mounts ledgered and live pass GET /api/settings 200 {"success":true,"data":{"manifests":[… (ledger packages/services/service-settings/src/settings-route-ledger.ts); GET /api/v1/datasources (admin) 200 [default, showcase_external] (ledger packages/services/service-datasource/src/datasource-route-ledger.ts). The retired unledgered-mount finding is not filed
    A8 datasource floor both sides pass GET /api/v1/datasources/drivers anonymous 401 UNAUTHENTICATED; phone.demo 403 PERMISSION_DENIED "Managing datasources requires the manage_platform_settings capability."; admin 200 driver catalog; GET /api/v1/datasources anonymous 401; both refusal bodies differ from the ENDPOINT_NOT_FOUND control
    A9 raw-app families answer (posture recorded) pass (os dev, NODE_ENV development) GET /api/v1/dev/metadata-events → 200 content-type text/event-stream (headers read with a 4 s timeout); POST same → 200 {"ok":true,"listeners":0,"reason":"manual-trigger"}; trigger-api POST /api/v1/automation/hooks/no_such_flow/bogus → 401 {"code":"INVALID_SIGNATURE","message":"Signature verification failed."} (structured, anonymous and admin alike); GET /api/v1/runtime/config anonymous 200; GET /api/v1/cloud-connection/status 200 bound:false; GET /_console → 302, GET /_console/ → 503 text/plain "The ObjectStack Console is not built…" (no Console build in this tree)
    A10 dispatcher meta state route live and correct pass GET /meta/object/showcase_task/state/status?from=in_review → 200 {"from":"in_review","next":["done","in_progress"]} = task.object.ts task_status_flow; ?from omitted → from:null, next:null; no-FSM field state/priority?from=high → next:null; not_a_real_object/state/status → 404 NOT_FOUND "Object not found" (handler body ≠ control)
    N0 (neg) no unit/getRoutes evidence pass every row is a live HTTP trace against :4844
    Probe (~2 min, admin / anonymous / phone.demo): anonymous state route → 401; trailing-slash GET /data/showcase_task/ → routing 404 (strict paths); anonymous GET /api/settings → 401; POST /data/showcase_task/actions/no_such_action → routing 404, which is correct — rest-route-ledger.ts:343 records that path as deliberately not a route; the dispatcher door POST /api/v1/actions/showcase_task/no_such_action answers its handler 404 RESOURCE_NOT_FOUND "…has no declaration…"; the five ledgers the item does not list also answer live: comment-reaction DELETE /comments/reactions/ → 404 RECORD_NOT_FOUND (handler), personal-view DELETE /personal-views/ → 404 RECORD_NOT_FOUND, sharing GET /sharing/shareable-objects → 200, messaging GET /messaging/preference-options → 200, print POST /print/records {} → 503 SERVICE_UNAVAILABLE "the print engine is not ready" (handler). No product findings; ledger-count drift parked (unchanged from 8e3d4a5).

    cli.migrate-meta-codemod (rev 7, cli) — PASS

    pins: none · live: CLI only, scratch copies /tmp/qa3b/C4/ws/examples/app-{plant,plant4,write,write3} (copies of examples/app-showcase, ws/packages + ws/node_modules symlinked to the tree), outputs /tmp/qa3b/C4/mig/ (planting patches plant.patch / plant4-extra.patch there)
    Registries (step 0): retired-defs 249 · retired-keys 307 · semantic 582 entry files. House sentence (step 1): os migrate meta --from 17 ×223 and --from 16 ×69 in spec .ts sources (+ one --from 98 in a test, 17 placeholder --from <N> forms; shared/retired-key.ts:57/188). Planted as raw literals (step-18 entries, so --from 17): data/Object:externalSharingModel and data/CurrencyConfig:precision (plain-literal field qa_amount) in semantic-zoo.object.ts, ui/ListView:pageName on business-unit org_chart. Each planted key's own load refusal prescribes Run \os migrate meta --from 17`` (9 such lines in the run's load output).

    # clause (short) verdict evidence
    A0 every planted spelling REPORTED with its successor pass log: Applied 5 mechanical change(s): lists views[3].listViews.org_chart.pageName: pageName → (removed) (view-page-mount-removed), objects[18].fields.qa_amount.currencyConfig.precision: precision → (removed) (currency-config-precision-removed), objects[18].externalSharingModel: externalSharingModel → (removed) (object-external-sharing-model-removed) + 2 stock flow-decision-mode-inclusive-explicit (flows[6]/[7]); exit 0
    A1 without --write the sources are untouched pass log: sha256 of all 181 scratch files (node_modules excluded) before vs after two plain runs, the --out run and the --json run: diff empty; the only new path is /tmp/qa3b/C4/mig/snap.json (outside the tree)
    A2 --out snapshot is the migrated stack; verdict honest pass snapshot: objects[18] showcase_semantic_zoo has no externalSharingModel, qa_amount.currencyConfig = {currencyMode:"fixed",defaultCurrency:"USD"}, views[3] org_chart keys without pageName, flows[6] any_found / flows[7] needs_exec mode 'inclusive' (= the 5 applied); ✓ Migrated stack is schema-valid agrees with --json schemaValid:true, applied 5. Warning arm on app-plant4 (an aged-out striped planted): ⚠ Migrated stack does not yet pass schema validation — 1 refusal left after the chain. Resolve them, then run \os validate`:+✗ views.3.listViews.org_chart: Unrecognized key(s) on this list view: `striped`…`, agreeing with its --json schemaValid:false
    A3 idempotent replay; empty range answered as empty pass canonical (the app-write tree after --write) --from 17 → no Applied block, ✓ Migrated stack is schema-valid, 505 advisories, exit 0; two identical planted-tree runs diff empty after the (Nms) normalization (only 774ms vs 772ms differed); --from 18 → ⚠ No migration step exists for protocol 18 → 18, so this run replayed nothing — that is not a finding that the metadata is canonical., no Nothing to migrate
    A4 below-floor --from refused pass --from 15 → ✗ Cannot migrate from protocol 15: the chain's support floor is 16 (ADR-0087 D3). Upgrade to protocol 16 by another path first, then re-run. exit 1; --json stdout {"error":"unsupported_from_major",…} exit 1
    A5 semantic entries are advisories, never "handled" pass separate 505 manual change(s) require your judgment: block with why:/verify: lines; the stock flow edits carry ↳ review the 2 edits above against the manual change [protocol 18] flow.nodes[].config.mode …; --write wrote none of them (diff)
    A6 --json exits 0 on success (#4873) pass meta --from 17 --json exit 0 (schemaValid true, applied 5); --write --json exit 0
    A7 --write writes exactly the provable edits pass --help: --write Rewrite the authored source files in place for each mechanical change traced to one literal in one project file, and the manifest's declared protocol range when the load would still refuse it; every other change is listed …; app-write: Wrote 5 of 5 mechanical change(s) into 3 file(s): (:1778/:293 flows/index.ts, :37/:29 semantic-zoo.object.ts, :28 business-unit.view.ts), ✓ Re-ran the chain over the written sources: no mechanical change remains., exit 0, no range block (showcase declares ^18); diff -r vs the byte copy = exactly those 5 sites in those 3 files; re-run without --write lists no Applied, advisory block identical to the dry run's; second --write → ℹ --write: the chain made no mechanical change here, so no file was written.; app-write3 --write --json exit 0, write.status 'written', write.written[] = 5 {conversionId, path, file, line}. Refusal arm on app-plant4: Wrote 2 of 3 mechanical change(s) into 1 file(s) + 1 mechanical change(s) left for you to apply by hand: … not written [helper]: the value is built by \Field.currency(…)`, not written as a literal, ✓ Re-ran the chain over the written sources: only the 1 change(s) left above remain.` (kind from the closed set — the contract working); its written zoo file is byte-equal to app-write's
    N0 (neg) prescription that is a dead end pass all 3 prescribing keys listed (A0)
    N1 (neg) a successor that does not parse pass all 3 successors are (removed); the written tree is schema-valid
    N2 (neg) run without --write mutating sources pass checksums (A1)
    N3 (neg) --write outside the sites / dropping a spelling pass diff (A7); N 5 = M 5 on app-write; N 2 + by-hand 1 = M 3 on app-plant4
    N4 (neg) below-floor silent no-op pass exit 1 (A4)
    N5 (neg) replay reports changes / identical runs differ pass A3
    N6 (neg) schema-valid printed with manual work outstanding pass the schema-valid line prints only where --json schemaValid is true; the tree with a leftover refusal prints the warning arm (A2)
    Probe (~2 min, CLI): bare --json → exit 1 missing_from_major; --from abc → exit 2 "INVOCATION ERROR — Parsing --from; Expected an integer … The command never ran"; --from 17 --to 16 → exit 0 ⚠ --from 17 is above --to 16, so the range is inverted and this run replayed nothing — that is not a finding… (honest); --from 17 --to 19 → exit 0, Chain: protocol 17 → 19 (this runtime implements protocol 18) with the 5 edits and no word that 18 → 19 has no step (parked at 8e3d4a5, unchanged). No findings.

    integration-system.connector-declarative-boot (rev 6) — PASS

    pins: packages/qa/dogfood test/showcase-declarative-mcp.dogfood.test.ts → 2/0 (HTTP: in-process booted showcase via bootShowcase, requests through stack.apiAs; negative[0] only scores, the rest corroborates halves; run alone, log /tmp/qa3b/C4/pins/dogfood.log) · packages/spec src/integration/connector-provider.test.ts → 15/0 (SPEC UNIT, schema parse — below A3/N1's build layer; 2-file spec run 47/0) · packages/services/service-automation src/connector-materialization.test.ts → 110/0 (in-process LiteKernel over a fake provider — below A4/A5's served-boot layer) · live: scratch copy /tmp/qa3b/C4/ws/examples/app-conn (showcase copy, ws/packages + ws/node_modules symlinked), default os dev compile under OS_PORT=4846, --log-level info --no-watch, db /tmp/qa3b/C4/db/conn.db (case d: conn-d.db). Its connectors index carries env-selected scratch cases (QA_CASE, QA_DISABLE_STATUS — the L3 recipe file, echo base repointed to my own :4849, never dialed). Logs /tmp/qa3b/C4/conn/{server,server-d,server-dis2}.log, compile-{a,b,c,d}.log.
    Self-ping check (RUNNER env fact): the ping's ping.body.data.uptime 20.524 s vs this boot's own GET /health uptime 20.595 s read 71 ms later — the dispatch landed on this boot.

    # clause (short) verdict evidence
    A0.rest showcase_status_api declarative / ready pass GET /automation/connectors (total 5): showcase_status_api origin=declarative state=ready; boot log INFO Connector registered: showcase_status_api (1 action handlers, origin: declarative)
    A0.openapi showcase_status_openapi declarative / ready pass origin=declarative state=ready + its boot line
    A0.mcp showcase_mcp_tools declarative / ready pass origin=declarative state=ready + its boot line
    A1 actions derived from the upstream pass mcp actions [echo_upper "Echo Upper"] = the only tool in scripts/mcp-fixture.mjs; openapi actions [getHealth "Server health probe"] = the only operation in status-openapi.json (/api/v1/health get getHealth)
    A2 both flows dispatch end to end pass POST …/showcase_declarative_connector_ping/trigger → 200, output ping.body = {success, data:{status ok, version 18.3.0, uptime 20.52}} from this boot; …/showcase_mcp_connector_echo/trigger → 200 output echo.structuredContent {"upper":"OBJECTSTACK"}; GET /automation/:name/runs → both completed
    A3 authoring gate rejects (a)–(c) with the located messages (build) pass os compile on the scratch copy, each exit 2: (a) ✗ connectors.4.providerConfig: \providerConfig` requires a `provider` — a connector entry with no provider is a catalog descriptor (ADR-0097).(b)✗ connectors.4.actions: Provider-bound connector instance 'qa_neg_b' must not author `actions` — the 'rest' provider derives them from the upstream at boot (ADR-0097 §5).(c)✗ connectors.4.authentication: Provider-bound connector instance 'qa_neg_c' must not inline secrets via `authentication`; reference credentials with `auth: { type, credentialRef }` instead (ADR-0097 §3).`
    A4 unknown provider is a hard boot error pass (d) builds clean (exit 0); boot: ✗ Plugin com.objectstack.service-automation failed to start - rollback complete: [Automation] connector instance 'qa_neg_d' declares provider 'no_such_provider', but no provider factory is registered. … Installed providers: [openapi, mcp, rest] (ADR-0097).; the process exited, /health 000, no process left
    A5 a disabled declaration is gone after reboot pass reboot with showcase_status_api enabled:false: listing total 4 [showcase_status_openapi, showcase_mcp_tools, rest, slack] without it, 0 Connector registered: showcase_status_api lines; ping dispatch → 400 FLOW_FAILED "no handler for 'showcase_status_api.request' — is the connector plugin registered?"; GET /meta/connector/showcase_status_api still serves it with enabled:false. Torn-down-on-reload is pinned in-process only (service-automation pin), not driven live
    N0 catalog descriptor stays out of the runtime listing pass showcase_erp_catalog: 0 hits in the listing; GET /meta/connector/showcase_erp_catalog → 200 (descriptor exists). Dogfood pin asserts the same at HTTP
    N1 malformed builds never pass clean pass (a)–(c) exit 2; (d) refused at boot as designed
    Probe (~1 min, admin / Mei / anonymous): anonymous listing → 401; Mei reads the listing (200); trigger of an unknown flow → 404 RESOURCE_NOT_FOUND "Flow 'qa_no_such_flow' not found". No findings.

    integration-system.connector-degraded-recovery (rev 7) — PASS

    pins: none · live: port 4847 · broken-upstream copy via node scripts/qa/qa-fixture.mjs broken-upstream-app --dir /tmp/qa3b/C4/brk (missing-script variant; db /tmp/qa3b/C4/db/conn-broken.db) and --variant config-fault --dir /tmp/qa3b/C4/cfg (db conn-cfgfault.db), each compiled in the copy under OS_PORT=4847 (os dev default compile, --log-level info --no-watch, no --ui), both torn down after (teardown --dir, exit 0). Logs /tmp/qa3b/C4/brk/server.log, /tmp/qa3b/C4/cfg/server.log (copies removed by teardown; the excerpts below are the record)

    # clause (short) verdict evidence
    A0 boot completes on a dead upstream; husk visible as degraded pass live: /health 200 at 16.1 s wall (including the copy's compile); log 10:44:19.697 "WARN Connector registered DEGRADED: showcase_mcp_tools (origin: declarative) — no actions and no handlers until its upstream is reachable…" degradedReason "connector-mcp provider: connector 'showcase_mcp_tools' could not reach its MCP server: MCP error -32000: Connection closed"; GET /api/v1/automation/connectors → showcase_mcp_tools state 'degraded', actions [], that degradedReason, beside 4 ready connectors
    A1 degrade only for CONNECTOR_UPSTREAM_UNAVAILABLE; config fault fatal pass broken upstream → degraded, boot completes (A0); config-fault twin (transport.kind 'carrier_pigeon') → 10:46:45.183 "ERROR Plugin startup failed: com.objectstack.service-automation … failed to materialize connector instance 'showcase_mcp_tools' via provider 'mcp' (ADR-0097). cause: … providerConfig.transport.kind must be 'stdio' or 'http'." → "WARN Rolling back started plugins..." → "✗ Plugin com.objectstack.service-automation failed to start - rollback complete"; process exited, /health 000, no process left
    A2 degraded dispatch fails fast, quotes degradedReason, distinct from no-such-connector pass POST /automation/showcase_mcp_connector_echo/trigger → 400 FLOW_FAILED in 0.036 s "Node 'echo' failed: connector_action 'echo': connector 'showcase_mcp_tools' is degraded — connector-mcp provider: … Connection closed. Dispatch is unavailable until that is resolved; the platform retries automatically."; comparator (live, the declarative-boot item's disabled-declaration boot, same engine path): "connector_action 'ping': no handler for 'showcase_status_api.request' — is the connector plugin registered?"
    A3 backoff 5 s doubling toward 300 s; reload retries immediately without reset pass log "next retry in" 5000 → 10000 → 20000 → 40000 ms over attempts 1–4; attempt spacing 2→3 10.04 s, 3→4 20.05 s (attempt 2 at +3.2 s, ahead of its 5 s slot — the kernel-ready reconcile pass, as at 8e3d4a5). Reload: PUT /meta/view/showcase_task.qa_c4_reload_view?mode=draft (200, 10:44:58.359) + POST …/publish (200, .483) → "Re-synced 33 flow(s) after metadata reload" 10:44:59.287 → attempt 5 at 10:44:59.327 (its scheduled slot was ~10:45:33) with "next retry in 80000ms": the count kept climbing and the delay kept doubling
    A4 recovery atomic and restart-free; ready; dispatch succeeds pass qa-fixture restore-upstream at 10:45:04 (file re-added, no config change); serve PID 13632 (started 10:44:07) unchanged throughout; 10:46:19.533 "INFO [Automation] recovered connector instance 'showcase_mcp_tools' via provider 'mcp' (1 action(s))" (the scheduled 80 s retry); descriptor state 'ready', actions [echo_upper], no degradedReason; trigger → 200 in 0.031 s, output {"echo.structuredContent":{"upper":"OBJECTSTACK"}}. (The Connector 'showcase_mcp_tools' replaced WARN printed on every failed retry too — not read as the signal, per the step)
    N0 (neg) a dead upstream takes the whole boot down pass boot completed (A0)
    N1 (neg) the husk vanishes from the listing pass present with state degraded on the pre-recovery read (A0)
    Probe (~1 min, admin): degraded dispatch latency 0.036 s (no hang); the reload view was DELETEd after (200). No findings.

    records-forms.named-import-mapping (rev 1, P2) — PASS

    pins: none · live: port 4845, db file:/tmp/qa3b/C4/db/import.db · admin (+ Mei Phone for a probe) · trace /tmp/qa3b/C4/import/trace.txt

    # clause (short) verdict evidence
    A0 foreign headers land on the mapped fields pass POST /api/v1/data/showcase_inquiry/import {format:"csv", mappingName:"showcase_inquiry_feed", csv: header Full Name,E-mail,Company,Channel,Message + 2 rows} → 200 {writeMode:"upsert", total 2, ok 2, created 2, updated 0}; read-back: {name "Ada C4imp", email ada.c4-imp@example.com, company Acme, source "website" (Webform mapped), message "hello one"} and {name "Bo C4imp", …, source "referral" (Partner Referral mapped)}
    A1 re-import idempotent (upsert on email) pass same body again → 200 {created 0, updated 2} with the same two ids; object total 5 before and 5 after (3 seeded + 2)
    A2 unknown mapping fails loudly pass mappingName "no_such_mapping" → 404 {"code":"MAPPING_NOT_FOUND","error":"No mapping artifact named "no_such_mapping" is registered"}; total unchanged (5), nothing written
    N0 (neg) duplicate re-import / silent positional fallback pass A1 no duplicates; A2 nothing written
    Probe (~2 min, admin + Mei): mappingName on the wrong object (POST /data/showcase_task/import) → 400 MAPPING_TARGET_MISMATCH "Mapping "showcase_inquiry_feed" targets object "showcase_inquiry", not "showcase_task""; mappingName "" → treated as no mapping: writeMode insert, every row refused INVALID_FIELD "Unknown field 'Full Name'" (loud, nothing written — not a positional fallback); a CSV whose headers the mapping does not know (Nickname,Mail) → 200 total 0 (no rows built, nothing written). Observed, not filed: Mei (member) importing the SAME file creates 2 NEW rows with the same emails (created 2, updated 0; object total 5 → 7) — showcase_inquiry is sharingModel 'private' and the upsert match only sees rows the caller can read, so a member's re-import cannot find the admin's rows. That is the RLS-scoped match working (matching an invisible row would leak its existence); A1's idempotence holds per caller. Recorded as a checklist note (Parked), no finding.

    Fails

    None. No clause failed in this group; no product-defect card to draft. Two known open findings were RE-MEASURED as asked and still reproduce at c419609 (not re-drafted): card-B-api-2 (private plain-HTTP AS metadata advertises an https issuer while the PRM and endpoints say http — ai.mcp-oauth-private-host-transport probe) and card-api-4 (a deployment-authored action on a packaged object listed twice by list_actions with a refusedFaces entry — ai.mcp-run-action-exposure-gate probe). The stdio get_record uncoded not-found text (ee9ad83 L2 note) also still reproduces.

    Parked

    • api-backend.route-ledger-live-parity — ledger universe drift (unchanged since 8e3d4a5). Title and fixtures.requires[1] say ELEVEN *route-ledger.ts; find packages -name '*route-ledger.ts' at c419609 lists SIXTEEN (/tmp/qa3b/C4/ledgers.txt; the five unnamed: plugin-audit comment-reaction, plugin-auth personal-view, plugin-sharing sharing, service-messaging messaging, service-print print — each answered non-routing-404 live). REST_ROUTE_LEDGER has 20 families (recycle-bin, user-work beyond steps[0]'s 18). The probe row POST /data/:object/actions/:name is correctly a routing 404 (rest-route-ledger.ts:343 says it is not a route) — a next run should not sample it as a ledger row.
    • api-backend.api-methods-verb-gate A1 prose stale (unchanged). "restore/purge absent even for an unrestricted object" — the spec closure for an unrestricted object (undefined apiMethods) now includes restore and purge (13 ops); the scored verify (equality with the spec closure) holds. Clause wording should follow api-derivation.ts.
    • cli.migrate-meta-codemod step 7 count. The step says the stock showcase carries "three edge-branched decisions" that omit mode; the run lists TWO flow-decision-mode-inclusive-explicit edits on the stock flows (flows[6] any_found, flows[7] needs_exec). --from 17 --to 19 still prints no word that 18 → 19 has no step (parked at 8e3d4a5).
    • ai.mcp-run-action-exposure-gate list size. Admin osk_ list_actions now 47 entries (Mei 35) vs 45/33 at 8e3d4a5 — more sys_* ai-exposed entries on this tip; the clause already says the list is not the showcase action alone, so no edit needed beyond the "37 entries" historical count.
    • records-forms.named-import-mapping — a clause it does not ask. A member re-importing a file an admin already imported creates duplicate-by-email rows on private-OWD showcase_inquiry (the upsert match sees only the caller's readable rows). That is the RLS-scoped match working, not a defect, but A1 could say "idempotent per caller" so a run does not read the cross-caller duplication as a FAIL.
    • ai.mcp-stdio-fail-closed — helper note for runners. A cd APP && setsid cmd & boot line records the subshell pid, not the session leader, so a kill -- -PID stop silently misses the server; record $! of a bare setsid … &. (My own driving slip, fixed mid-run; no verdict rests on it.)
    • steps[3] of ai.mcp-oauth-private-host-transport (real client from a second machine): blocked(fixture) — no second machine; scores no clause.
  5. objectstack-fleet commented on Nov 13, 2026

    @objectstack-fleet
    ContributorAuthor

    qa#3 · os-tesla · session_01L9qibw6yXcLi9aRRBpPT4W

    Close-out 2026-11-13T10:54Z.

    Extracted: #33083 · ai.mcp-oauth-private-host-transport · observed beside the clauses
    Extracted: #33029 · ai.mcp-run-action-exposure-gate · observed beside the clauses
    Extracted: #33030 · ai.mcp-coded-refusals-and-runtime-exposure · observed beside the clauses
    Parked-on: #32908 · checklist-accuracy notes and fixture gaps of this slice

  6. objectstack-fleet commented on Nov 13, 2026

    @objectstack-fleet
    ContributorAuthor

    qa#3 · os-tesla · session_01L9qibw6yXcLi9aRRBpPT4W

    Closed 2026-11-13T10:55Z: the c419609 api delta is judged; close-out above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions