matches",
+ "evidence": "the upload trace + get-session + avatar screenshot"
+ },
+ {
+ "clause": "avatar remove clears the image: updateUser({image:null}) empties the stored image and the initials fallback returns",
+ "oracle": "api",
+ "verify": "post-remove get-session image is null/empty; the card shows getUserInitials fallback",
+ "evidence": "the get-session + the screenshot"
+ },
+ {
+ "clause": "password change verifies the CURRENT password and rotates the credential: the correct current password + new one succeeds, the new password then signs in and the old one is refused",
+ "oracle": "api",
+ "verify": "change-password 2xx; sign-in with new password 2xx; sign-in with old password non-2xx",
+ "evidence": "the change response + the two sign-in attempts"
+ },
+ {
+ "clause": "a wrong current password is refused server-side — the verification is real, not a client-only confirm",
+ "oracle": "api",
+ "verify": "change-password with an incorrect current password returns non-2xx and the credential is unchanged (old password still signs in)",
+ "evidence": "the refusal + a subsequent old-password sign-in still working"
+ },
+ {
+ "clause": "a passwordless user gets set-initial-password, not change-password: with hasLocalPassword false the card omits the current-password field, setInitialPassword creates the local credential, and email+password sign-in then works",
+ "oracle": "api",
+ "verify": "the set-initial call 2xx with no current-password; a subsequent email+password sign-in for that user 2xx (was impossible before)",
+ "evidence": "the set-initial response + the new sign-in"
+ }
+ ],
+ "negative": [
+ "a name/avatar 'save' that updates the header but does NOT persist (gone after reload) is a FAIL — updateUser must write server-side",
+ "an avatar stored as an inline data: URL rather than a committed storage object is a FAIL (the item asserts a real storage write)",
+ "change-password succeeding with a WRONG current password is a security FAIL — the current-password check must be server-enforced",
+ "offering the passwordless user a change-password form with a required current password they don't have (locking them out of setting one) is a FAIL — set-initial is the correct branch"
+ ],
+ "traps": ["hydration-race", "stale-console-bundle"],
+ "source": [
+ "objectui apps/console/src/pages/system/ProfilePage.tsx (updateUser name/image; useUpload avatar; PasswordCard changePassword vs setInitialPassword gated on hasLocalPassword; email immutable; data-testids profile-avatar-file/-upload-btn/-remove-btn)",
+ "packages/plugins/plugin-auth/src/auth-route-ledger.ts (POST /api/v1/auth/update-user=auth.updateUser, POST /api/v1/auth/change-password=auth.changePassword, GET /api/v1/auth/get-session=auth.me)",
+ "packages/platform-objects/src/identity/sys-account.object.ts (previous_password_hashes ring — ADR-0069 D1 reuse-prevention backs change-password)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-08", "change": "new item: self-service name + avatar (real storage write, persists) and password change with current-password verification, plus the passwordless set-initial branch, grounded in ProfilePage.tsx + the auth route ledger (PENDING-GAPS §B)", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "identity-auth.org-membership-team-management",
+ "title": "Setup Organization page resolves the active org and drives member/invitation/team management through the better-auth org endpoints — non-admins refused server-side",
+ "since": "v17",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "mixed",
+ "personas": ["tenant admin / org owner", "a target org member", "a non-admin org member (forger)"],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the organization capability mounted (always mounted per ADR-0081 D1; plugin-auth's default-org bootstrap guarantees an org to manage) and a session with an active organization so {current_org_id} resolves",
+ "at least two members in the org so role changes and removals have a target that is not the admin"
+ ]
+ },
+ "steps": [
+ "sign in as the org owner/admin and open Setup → People & Org → Organization (nav_organization: type object, objectName sys_organization, recordId {current_org_id}, ADR-0081); screenshot the org record page and confirm {current_org_id} resolved to the session's active org (not the list fallback)",
+ "confirm the record page exposes the Members / Invitations / Teams tabs with the better-auth row actions (GET list-members, list-invitations, list-teams feed them)",
+ "change a member's role: POST /api/v1/auth/organization/update-member-role (client organizations.updateMemberRole) to one of the 4-name vocabulary {owner, admin, member, guest}; read the membership back and confirm the new role",
+ "prove the role change bites: an operation the new role gates flips outcome for that member (e.g. promote to admin → an org-admin-only action now permitted; demote → refused)",
+ "rename the organization: POST /api/v1/auth/organization/update (organizations.update) with a new name; re-read GET get-full-organization and confirm the rename persisted and the nav label follows",
+ "remove a member: POST /api/v1/auth/organization/remove-member (organizations.removeMember); confirm the member's org-scoped access SHRINKS — a resource they could read as a member now refuses",
+ "create a team + add members: POST /api/v1/auth/organization/create-team (organizations.teams.create), then POST /api/v1/auth/organization/add-team-member (organizations.teams.addMember) for two users; read sys_team_member and confirm two join rows exist (unique on team_id+user_id)",
+ "both-sides gate: as a NON-admin member, fire update-member-role / remove-member / update / create-team / add-team-member directly and capture each server refusal; screenshot that the management affordances are absent in the non-admin's UI"
+ ],
+ "acceptance": [
+ {
+ "clause": "{current_org_id} resolves to the session's active organization: the Organization nav opens that org's record page (Members/Invitations/Teams tabs), not the raw sys_organization list — the ADR-0081 active-org token is wired",
+ "oracle": "screenshot",
+ "verify": "the org record page renders for the active org id (cross-checked against GET /api/v1/auth/organization/get-active-member / get-full-organization); the three management tabs are present",
+ "evidence": "the org-page screenshot + the get-active/get-full response"
+ },
+ {
+ "clause": "update-member-role writes a role from the 4-name vocabulary and it bites: the membership read shows the new role (one of owner/admin/member/guest) and a role-gated operation flips outcome accordingly",
+ "oracle": "api",
+ "verify": "GET list-members after update-member-role shows the new role; the same gated request returns 2xx vs 403 before/after for that member",
+ "evidence": "the membership read + the bracketing gated requests"
+ },
+ {
+ "clause": "rename via organization/update persists: get-full-organization returns the new name and the surface follows",
+ "oracle": "api",
+ "verify": "GET get-full-organization after the update shows the renamed org",
+ "evidence": "the before/after org reads"
+ },
+ {
+ "clause": "remove-member shrinks access: after removal the ex-member is refused a resource they could reach as a member — removal is an authorization change, not just a roster edit",
+ "oracle": "api",
+ "verify": "an org-scoped request that succeeded for the member returns a refusal after remove-member",
+ "evidence": "the before/after member requests"
+ },
+ {
+ "clause": "create-team + add-team-member land real join rows: sys_team_member has one row per (team, user), created through the better-auth org endpoints (generic CRUD on the managed table is suppressed)",
+ "oracle": "api",
+ "verify": "GET /api/v1/data/sys_team_member (or list-team-members) shows the two membership rows for the new team; the (team_id,user_id) pairs match",
+ "evidence": "the sys_team_member read"
+ },
+ {
+ "clause": "the gate holds both ways: a non-admin's direct update-member-role / remove-member / update / create-team / add-team-member calls are each refused server-side, and the management affordances are absent in the non-admin UI",
+ "oracle": "api",
+ "verify": "each forged non-admin call returns non-2xx and leaves membership/org/team state unchanged; the non-admin org page shows no management actions",
+ "evidence": "the forged-request responses + the non-admin screenshot"
+ }
+ ],
+ "negative": [
+ "an org management surface where the affordance is hidden but the forged endpoint succeeds for a non-admin is a FAIL — the server is the authority (ADR-0057 D10)",
+ "remove-member that drops the roster row but leaves the ex-member's org-scoped access intact is a FAIL — removal must change authorization",
+ "a role written outside the {owner, admin, member, guest} vocabulary, or a role change that does not flip any gate, is a FAIL",
+ "the Organization nav landing on the raw sys_organization list because {current_org_id} did not resolve (when an active org exists) is a FAIL of the ADR-0081 wiring"
+ ],
+ "traps": ["wrong-persona", "dispatcher-vs-hono-route", "hydration-race"],
+ "source": [
+ "packages/platform-objects/src/apps/setup-nav.contributions.ts (nav_organization recordId {current_org_id}, ADR-0081; Teams/Invitations always mounted per ADR-0081 D1)",
+ "packages/plugins/plugin-auth/src/auth-route-ledger.ts (organization family: update-member-role, remove-member, update, create-team, add-team-member, list-members/teams/invitations, get-active-member, get-full-organization)",
+ "packages/spec/src/identity/organization.zod.ts (MemberSchema role vocabulary: owner/admin/member/guest)",
+ "packages/platform-objects/src/identity/sys-team-member.object.ts (add_team_member/remove_team_member actions → organization/add-team-member; unique team_id+user_id; requiresFeature organization)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-08", "change": "new item: Setup Organization page {current_org_id} resolution (ADR-0081) with Members/Invitations/Teams tabs, update-member-role (4-name vocab)/remove-member/rename, create-team + add-team-member → sys_team_member rows, non-admin refused server-side (PENDING-GAPS §B). Teams membership deep-tested in identity-auth.teams-bu-membership; org-member management stays here", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "identity-auth.teams-bu-membership",
+ "title": "Teams and the Business Unit tree: memberships land real rows, and a BU placement widens/narrows a scoped persona's read along the tree",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "mixed",
+ "personas": ["org admin", "two members to place on a team", "a scope-limited persona whose read follows the BU tree"],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the organization capability mounted (for the team half — sys_team / sys_team_member via the better-auth org endpoints)",
+ "the sys_business_unit tree available (managedBy 'platform' — writable over the data API, unlike the better-auth identity tables) with at least a root company node to parent a child under",
+ "for the scope-geometry clause: a sharing/scope configuration that actually consumes the BU tree (recipient_type business_unit sharing rules, or a scope-depth persona) — if no showcase geometry consumes BU membership, run that clause blocked(fixture) and record it"
+ ],
+ "knownGaps": [
+ "whether a stock showcase persona's read is scoped BY the business-unit tree depends on the seeded sharing/scope config; if none consumes it, the tree-widening clause is blocked(fixture) — the team-membership and BU-placement clauses still run"
+ ]
+ },
+ "steps": [
+ "create a team via POST /api/v1/auth/organization/create-team (or the sys_team create_team action) and add two members via POST /api/v1/auth/organization/add-team-member; read sys_team_member and confirm two rows (unique team_id+user_id)",
+ "remove one via remove-team-member and confirm the join row is gone (the endpoint keys on the (teamId,userId) pair, not the row id)",
+ "create a CHILD sys_business_unit under an existing root: POST /api/v1/data/sys_business_unit with kind (company|division|department|office|cost_center) and parent_business_unit_id = the root's id; confirm it appears in the Org Chart tree view under its parent",
+ "place a user in the child BU: create a sys_business_unit_member row (business_unit_id, user_id, function_in_business_unit member|lead|deputy, is_primary); confirm the placement via a data-API read",
+ "if BU scope geometry is configured: as the scope-limited persona, record the row set visible BEFORE the placement, then place the persona (or a record they can see) into the child BU and re-read — the visible set should WIDEN or NARROW along the tree per the geometry (cross-ref identity-auth via access-security.scope-depth-asymmetry which owns the depth matrix)",
+ "move the child BU to a different parent (re-parent parent_business_unit_id) and, if geometry consumes it, re-read the scoped persona's rows to confirm the read follows the new tree position",
+ "negative: attempt the team mutations and the BU writes as a non-admin and capture the refusals"
+ ],
+ "acceptance": [
+ {
+ "clause": "team membership lands real rows: create-team + add-team-member produce exactly one sys_team_member per (team,user), and remove-team-member deletes exactly that pair",
+ "oracle": "api",
+ "verify": "sys_team_member reads before/after each mutation; the (team_id,user_id) rows match the two added members and the removal drops exactly one",
+ "evidence": "the sys_team_member reads"
+ },
+ {
+ "clause": "a child business unit attaches to the tree: the new sys_business_unit carries parent_business_unit_id = the root and renders under it in the Org Chart tree view",
+ "oracle": "api",
+ "verify": "GET /api/v1/data/sys_business_unit for the child shows the parent id; the tree view (org_chart) renders it nested (screenshot after render)",
+ "evidence": "the BU read + the org-chart screenshot"
+ },
+ {
+ "clause": "a user placement is a real sys_business_unit_member row with its function/primary attributes",
+ "oracle": "api",
+ "verify": "the sys_business_unit_member read shows business_unit_id + user_id + function_in_business_unit + is_primary",
+ "evidence": "the membership read"
+ },
+ {
+ "clause": "when scope geometry consumes the BU tree, a placement changes a scoped persona's visible rows along the tree — widening (placed higher / into a parent that expands subordinates) or narrowing accordingly; re-parenting moves the read with it",
+ "oracle": "api",
+ "verify": "the scoped persona's row set before vs after the placement/re-parent differs exactly by the subtree the geometry expands; if no geometry consumes BU membership this clause is blocked(fixture) and recorded",
+ "evidence": "the before/after scoped reads (or the recorded block)"
+ },
+ {
+ "clause": "team and BU mutations are admin-gated: a non-admin's create-team/add-team-member and BU writes are refused server-side",
+ "oracle": "api",
+ "verify": "the forged non-admin calls return non-2xx and leave sys_team_member / sys_business_unit(_member) unchanged",
+ "evidence": "the refusals + the unchanged reads"
+ }
+ ],
+ "negative": [
+ "add-team-member that does not create a sys_team_member row (or creates duplicates past the unique team_id+user_id constraint) is a FAIL",
+ "a BU placement that the scope geometry claims to consume but which does NOT move the scoped persona's read is a FAIL — the tree must be load-bearing, not decorative",
+ "silently degrading a BU scope to own/org when an intermediate depth is authored is the ADR-0049 loud-degradation concern — record it (see PENDING-GAPS §H five-depth note), do not tick it green"
+ ],
+ "traps": ["wrong-persona", "seed-data-thin", "hydration-race"],
+ "source": [
+ "packages/platform-objects/src/identity/sys-team-member.object.ts (add_team_member/remove_team_member → organization/add-team-member|remove-team-member; unique team_id+user_id)",
+ "packages/platform-objects/src/identity/sys-business-unit.object.ts (canonical BU tree ADR-0057 D2; kind enum; parent_business_unit_id self-ref; org_chart tree view; managedBy 'platform' — writable over the data API)",
+ "packages/platform-objects/src/identity/sys-business-unit-member.object.ts (user↔BU placement: function_in_business_unit member/lead/deputy, is_primary)",
+ "docs/qa/platform-checklist/areas/access-security.json (access-security.scope-depth-asymmetry — the depth matrix this cross-references for the tree-widening geometry)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-08", "change": "new item: team membership rows (create-team/add/remove) + child business-unit creation and user placement on the sys_business_unit tree, with a scope-geometry-consumes-the-tree clause cross-referencing access-security.scope-depth-asymmetry (PENDING-GAPS §C). Org-member management lives in identity-auth.org-membership-team-management", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "identity-auth.self-service-password-reset",
+ "title": "Forgot-password: request → token captured at the dev mail transport → reset; old password refused, expired/reused token refused loudly",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "api",
+ "personas": ["anonymous visitor (holds no session)", "the account owner (before and after reset)"],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "email+password sign-in enabled and an email transport configured — for a hermetic run use the dev `log` mail transport (EMAIL_TRANSPORT_PROVIDERS 'log' → LogTransport) so the reset email (and its token) is captured in the transport output rather than actually sent",
+ "a target user with a known current password"
+ ],
+ "knownGaps": [
+ "capturing the reset TOKEN requires reading it from the dev mail transport (LogTransport) output — if the deployment sends via a real provider with no capture hook, the token-capture step is blocked(fixture); the request/refusal clauses that don't need the token still run"
+ ]
+ },
+ "steps": [
+ "as anonymous, request a reset: POST /api/v1/auth/request-password-reset with the target email (NOTE the live route is request-password-reset / reset-password — the spec's AuthEndpointPaths says /forget-password, a divergence; trust the live server per RUNNER, and file the doc divergence)",
+ "capture the reset email at the dev mail transport (LogTransport) and extract the token (the link is GET /api/v1/auth/reset-password/:token)",
+ "complete the reset: POST /api/v1/auth/reset-password with the token + a new password; capture the response",
+ "sign in with the NEW password (2xx) and confirm the OLD password is now refused",
+ "reuse the SAME token a second time (POST /api/v1/auth/reset-password again) and capture the loud refusal — a consumed token must not reset again",
+ "request a fresh reset, let the token expire (or use a tampered/garbage token) and confirm the reset is refused with a named error, never a silent success",
+ "negative: request-password-reset for an unknown email should not leak whether the account exists (anti-enumeration) — record the response shape",
+ "confirm password-reuse prevention if configured: resetting to the OLD password is refused where password_history_count > 0 (previous_password_hashes ring, ADR-0069 D1)"
+ ],
+ "acceptance": [
+ {
+ "clause": "the request issues a real reset artifact: POST /api/v1/auth/request-password-reset produces an email at the dev transport carrying a reset token/link (GET /api/v1/auth/reset-password/:token)",
+ "oracle": "log",
+ "verify": "the LogTransport output for the request contains the reset link with a token; the request response is a non-leaking 2xx/accepted",
+ "evidence": "the captured transport output"
+ },
+ {
+ "clause": "the token completes the reset and rotates the credential: POST /api/v1/auth/reset-password with the token + new password succeeds, the new password signs in and the old one is refused",
+ "oracle": "api",
+ "verify": "reset 2xx; sign-in new 2xx; sign-in old non-2xx",
+ "evidence": "the reset response + the two sign-in attempts"
+ },
+ {
+ "clause": "a consumed token cannot be reused: a second reset with the same token is refused with a named error",
+ "oracle": "api",
+ "verify": "the second reset-password returns non-2xx (token already used)",
+ "evidence": "the second-attempt response"
+ },
+ {
+ "clause": "an expired or tampered token is refused LOUDLY — never a silent success that leaves the password unchanged while reporting OK",
+ "oracle": "api",
+ "verify": "reset with an expired/garbage token returns a named non-2xx and the credential is unchanged (old password still signs in)",
+ "evidence": "the refusal + a subsequent old-password sign-in"
+ },
+ {
+ "clause": "request does not leak account existence: request-password-reset for an unknown email returns the same non-committal shape as for a known one (anti-enumeration)",
+ "oracle": "api",
+ "verify": "the known-email and unknown-email request responses are indistinguishable in status/body",
+ "evidence": "the two request responses"
+ }
+ ],
+ "negative": [
+ "a reset that returns 2xx but leaves the password unchanged (token not actually honored) is a FAIL — the new password must sign in and the old must not",
+ "a reused or expired token that still resets the password is a security FAIL — tokens are single-use and time-bounded",
+ "request-password-reset returning a DIFFERENT response for known vs unknown emails is an account-enumeration FAIL",
+ "a silent success on a garbage token (no error, no change) is a FAIL — the spec's contract is a loud refusal"
+ ],
+ "traps": ["dispatcher-vs-hono-route", "wrong-persona"],
+ "source": [
+ "packages/plugins/plugin-auth/src/auth-route-ledger.ts (BETTER_AUTH_MOUNTED_SURFACE: POST /api/v1/auth/request-password-reset, POST /api/v1/auth/reset-password, GET /api/v1/auth/reset-password/:token — the LIVE routes; AuthEndpointPaths.forgetPassword='/forget-password' is the divergent spec name)",
+ "packages/plugins/plugin-email/src/transports/index.ts (EMAIL_TRANSPORT_PROVIDERS 'log' → LogTransport — the dev capture transport)",
+ "packages/platform-objects/src/identity/sys-account.object.ts (previous_password_hashes ring — ADR-0069 D1 reuse-prevention)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-08", "change": "new item: forgot-password round trip (request-password-reset → dev-transport token capture → reset-password), old-password-refused/new-works, single-use + expiry refusals, anti-enumeration, with the token-capture dev-mail dependency recorded as a knownGap (PENDING-GAPS §B)", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "identity-auth.oauth-app-consent-loop",
+ "title": "OAuth provider: register an app (secret shown once), run the authorization-code consent loop — approve mints tokens + a consent record, deny mints none",
+ "since": "v17",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "mixed",
+ "personas": ["an org admin registering the OAuth client", "the resource-owner user granting/denying consent"],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the better-auth oidcProvider plugin configured (the oauth2/* routes are gated: auth-route-ledger 'requires: oidcProvider') — stock showcase ships no configured OAuth provider flow, so this item is blocked(fixture) until one is provisioned",
+ "a registered client + a redirect URI to complete the authorization-code round trip"
+ ],
+ "knownGaps": [
+ "sys_oauth_consent is apiEnabled:false (apiMethods []) — the consent ROW is not readable over the data API; verify consent via the auth surface GET /api/v1/auth/oauth2/get-consents (the row's presence implies consent for the listed scopes; the old consent_given boolean was removed)"
+ ]
+ },
+ "blocked": { "by": "fixture", "ref": "no stock showcase oidcProvider flow — needs a configured OAuth provider + client, same fixture class as identity-auth.sso-enforced-first-paint / linked-accounts-social" },
+ "steps": [
+ "as admin, register an OAuth client: POST /api/v1/auth/oauth2/create-client (client oauth.applications.register, requires oidcProvider); capture the response and the client_secret — revealed ONCE at registration",
+ "re-read the client via GET /api/v1/auth/oauth2/get-client and confirm the secret is NOT returned again (only client_id / public metadata)",
+ "begin the authorization-code flow: GET /api/v1/auth/oauth2/authorize with the client_id, redirect_uri, scope and state; as the resource owner, land on the consent page and screenshot the requested-scopes list",
+ "APPROVE: POST /api/v1/auth/oauth2/consent (oauth.consent) accept; follow the redirect, exchange the code at POST /api/v1/auth/oauth2/token, and capture the issued access/refresh tokens",
+ "confirm a consent record now exists: GET /api/v1/auth/oauth2/get-consents shows a consent for this client covering the approved scopes (sys_oauth_consent row — not data-API readable)",
+ "run the flow again for the SAME client+scopes and confirm the consent screen is SKIPPED (the recorded consent short-circuits it)",
+ "DENY path: start a fresh authorize with an added scope (forcing consent), deny it, and confirm NO tokens are issued and NO new consent is recorded",
+ "mine-view scoping: confirm the consent surface shows the CALLER's consents only — another user cannot see this user's oauth2 consents"
+ ],
+ "acceptance": [
+ {
+ "clause": "the client_secret is revealed exactly once at registration: create-client returns it, and get-client afterwards returns only public metadata (no secret)",
+ "oracle": "api",
+ "verify": "the create-client response carries client_secret; the follow-up get-client does not",
+ "evidence": "the create + get responses"
+ },
+ {
+ "clause": "the consent page lists the requested scopes before any token is minted — the user sees what they are authorizing",
+ "oracle": "screenshot",
+ "verify": "the consent screen enumerates the scopes from the authorize request",
+ "evidence": "the consent screenshot"
+ },
+ {
+ "clause": "approve mints tokens AND records consent: the token exchange returns access/refresh tokens and get-consents shows a consent covering the approved scopes for this client",
+ "oracle": "api",
+ "verify": "POST oauth2/token returns tokens; GET oauth2/get-consents lists the client+scopes (the sys_oauth_consent row's existence is the consent — no consent_given flag)",
+ "evidence": "the token response + the get-consents read"
+ },
+ {
+ "clause": "a recorded consent short-circuits the screen: a repeat authorize for the same client+scopes skips consent and proceeds to the redirect",
+ "oracle": "network",
+ "verify": "the second authorize does not render the consent page; it redirects with a code directly",
+ "evidence": "the second-flow trace"
+ },
+ {
+ "clause": "deny mints nothing: denying consent issues no tokens and records no consent — the deny path is a clean no-op on credentials",
+ "oracle": "api",
+ "verify": "no token is exchanged after deny; get-consents shows no new consent for the denied scope set",
+ "evidence": "the deny trace + the get-consents read"
+ },
+ {
+ "clause": "consents are mine-view scoped: get-consents returns only the caller's own consents",
+ "oracle": "api",
+ "verify": "a second user's get-consents does not include this user's consent",
+ "evidence": "the two get-consents reads"
+ }
+ ],
+ "negative": [
+ "the client_secret being retrievable after registration (via get-client or the data API) is a FAIL — it is a show-once credential",
+ "approve that mints tokens but records NO consent (so the screen re-prompts forever) — or deny that still mints tokens — is a FAIL",
+ "one user seeing another user's oauth2 consents is a scoping FAIL"
+ ],
+ "traps": ["dispatcher-vs-hono-route", "wrong-persona", "hydration-race"],
+ "source": [
+ "packages/plugins/plugin-auth/src/auth-route-ledger.ts (oauth-provider family, requires oidcProvider: oauth2/create-client=oauth.applications.register, get-client, consent=oauth.consent, get-consents, oauth2/authorize, oauth2/token)",
+ "packages/platform-objects/src/identity/sys-oauth-consent.object.ts (row implies consent for listed scopes — consent_given removed; apiEnabled:false so verify via get-consents, not the data API)",
+ "packages/platform-objects/src/identity/sys-oauth-application.object.ts + setup-nav.contributions.ts (nav_oauth_apps → Setup OAuth Applications)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-08", "change": "new item: OAuth client registration (secret once) + authorization-code consent loop (approve mints tokens + consent record, deny mints none, recorded consent short-circuits), mine-view scoped; blocked(fixture) pending a configured oidcProvider flow (PENDING-GAPS §C)", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "identity-auth.linked-accounts-social",
+ "title": "Linked accounts: link a social/OIDC identity through the redirect round-trip → a sys_account row appears in mine-view; unlink removes it; provider-less boot degrades honestly",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "mixed",
+ "personas": ["a signed-in user linking a second identity", "the same user after unlinking"],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "at least one social/OIDC provider configured (socialProviders or oidcProviders) so link_social has a provider to dance with — stock showcase ships none, so this item is blocked(fixture) until a provider is configured",
+ "the Account 'Identity Links' surface (nav_accounts → sys_account) reachable"
+ ]
+ },
+ "blocked": { "by": "fixture", "ref": "no stock showcase social/OIDC IdP — link_social needs a configured provider, same fixture class as identity-auth.sso-enforced-first-paint / oauth-app-consent-loop" },
+ "steps": [
+ "as a signed-in user, open Account → Identity Links (sys_account `mine` view, filter user_id={current_user_id}); screenshot the initial link set",
+ "invoke link_social for a configured provider: the action is type:'url' — GET /api/v1/auth/sign-in/social?provider=&callbackURL=/_console/apps/account/sys_account (full-page navigation, NOT XHR, so the OAuth 302 dance and link cookie work); complete the provider round-trip and land back on the Identity Links view",
+ "confirm a sys_account row now exists for that provider in the caller's mine-view (provider_id = the provider, user_id = the caller, issuer stamped)",
+ "unlink it: the unlink_account action → POST /api/v1/auth/unlink-account with accountId = the sys_account ROW id (better-auth 1.7 keys on the row id); confirm the row is gone from mine-view",
+ "both-sides / degradation: on a boot with NO provider configured, confirm link_social degrades honestly — the affordance is absent or names the missing provider, rather than offering a link that dead-ends (open-edition honest-degradation posture)",
+ "confirm sys_account is read-only over the data API — a forged direct insert/delete is refused (apiMethods ['get','list'], writes 405)"
+ ],
+ "acceptance": [
+ {
+ "clause": "linking a social identity creates a sys_account row in the caller's mine-view after the redirect round-trip completes",
+ "oracle": "api",
+ "verify": "GET /api/v1/data/sys_account (mine view) after the link shows a new row with provider_id = the provider and user_id = the caller; issuer is stamped",
+ "evidence": "the post-link mine-view read"
+ },
+ {
+ "clause": "the link surface renders the caller's own links only (mine-view scoped) — screenshot-confirmed",
+ "oracle": "screenshot",
+ "verify": "Identity Links shows the caller's provider rows; a different user's links are not present",
+ "evidence": "the Identity Links screenshot"
+ },
+ {
+ "clause": "unlink removes the row: unlink_account keyed on the sys_account row id deletes exactly that link from mine-view",
+ "oracle": "api",
+ "verify": "POST /api/v1/auth/unlink-account with the row's accountId; a follow-up mine-view read no longer contains it",
+ "evidence": "the unlink response + the follow-up read"
+ },
+ {
+ "clause": "a provider-less boot degrades honestly: link_social is absent or names the missing provider — never a link affordance that dead-ends",
+ "oracle": "screenshot",
+ "verify": "with no provider configured, the Identity Links surface shows no dead link action (or an explicit unavailable state)",
+ "evidence": "the provider-less screenshot"
+ },
+ {
+ "clause": "sys_account is read-only over the data API — links are mutated only through the auth endpoints, not raw row writes",
+ "oracle": "api",
+ "verify": "a forged POST/DELETE to /api/v1/data/sys_account returns 405 (apiMethods ['get','list'], identity write guard ADR-0092 D2)",
+ "evidence": "the forged-write response"
+ }
+ ],
+ "negative": [
+ "a link that appears in the UI but does not create a sys_account row (client-only) is a FAIL — the row is the durable identity link",
+ "unlink that hides the row from the list but leaves the sys_account (so the provider still signs the user in) is a FAIL",
+ "one user's identity links appearing in another's mine-view is an RLS FAIL",
+ "a provider-less boot offering a link_social action that navigates to a dead endpoint is a dishonest-degradation FAIL"
+ ],
+ "traps": ["wrong-persona", "dispatcher-vs-hono-route", "hydration-race"],
+ "source": [
+ "packages/platform-objects/src/identity/sys-account.object.ts (link_social type:'url' → /api/v1/auth/sign-in/social?provider=&callbackURL=; unlink_account → /api/v1/auth/unlink-account accountId=row id; mine view user_id={current_user_id} vs all_links; provider options; apiMethods ['get','list'])",
+ "packages/plugins/plugin-auth/src/auth-route-ledger.ts (POST link-social=auth.accounts.linkSocial, GET list-accounts=auth.accounts.list, POST unlink-account=auth.accounts.unlink)",
+ "packages/platform-objects/src/apps/setup-nav.contributions.ts (nav_accounts → 'Identity Links', objectName sys_account)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-08", "change": "new item: social/OIDC account linking round-trip → sys_account mine-view row, unlink removal, provider-less honest degradation; blocked(fixture) pending a configured IdP (PENDING-GAPS §C)", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "identity-auth.identity-import-wizard",
+ "title": "Admin CSV identity import: password-policy auto/temporary drive per-row credentials, imported users sign in, non-admins denied",
+ "since": "v17",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "mixed",
+ "personas": ["platform admin (running the import)", "an imported user (signing in afterwards)", "a non-admin (forger)"],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "platform-admin access to the identity Import Wizard (objectui app-shell identityImport.ts wraps the wizard onto POST /api/v1/auth/admin/import-users — the sys_user import cannot use the generic /data import because it must go through better-auth password hashing + credential creation)",
+ "a small scratch CSV of users (email required; the wizard parses it client-side into rows[])",
+ "for the `auto` invite-reachable path: an email/SMS transport so an invitation can be issued (dev `log` transport is sufficient to observe it); unreachable rows fall back to a one-time password"
+ ],
+ "knownGaps": [
+ "observing the `auto` INVITE path needs a transport to capture the invitation; with the dev log transport it is observable, otherwise the invite-vs-fallback split is blocked(fixture). The one-time passwords (auto-fallback + all of temporary) are returned ONLY in the response — the result step must reveal them; they are never persisted"
+ ]
+ },
+ "steps": [
+ "as admin, open the Import Wizard for Users, upload the scratch CSV, map email (and name/phone) — the wizard drives POST /api/v1/auth/admin/import-users in ≤500-row batches with the chosen passwordPolicy",
+ "run with passwordPolicy `auto` (the default, framework#3236): deliverable rows get an invitation (set-your-password email/SMS), unreachable rows fall back to a one-time password revealed once in the result step; capture the per-row result (action + any temporaryPassword) and screenshot the reveal",
+ "run a second import with passwordPolicy `temporary`: EVERY row gets a per-row one-time password (no invitations); confirm each result row carries a temporaryPassword shown once",
+ "sign in as an imported user: for a `temporary` (or auto-fallback) row use the revealed one-time password; for an `auto` invite row, follow the invitation to set a password; confirm email+password sign-in then works",
+ "idempotency: re-run the SAME CSV in upsert mode (matchBy email) and confirm it upserts (updates) rather than duplicating — the endpoint matches on email/phone",
+ "confirm one-time passwords are NOT persisted anywhere: they appear only in the import response/result step, never in a later read of the user or any audit row",
+ "both-sides gate: as a NON-admin, POST /api/v1/auth/admin/import-users directly and capture the refusal (the endpoint is platform-admin-gated)"
+ ],
+ "acceptance": [
+ {
+ "clause": "policy `auto` splits per row: deliverable rows get an invitation, unreachable rows fall back to a one-time password revealed once — the wizard result surfaces both outcomes",
+ "oracle": "api",
+ "verify": "the import response's per-row results show action + (for fallback rows) a temporaryPassword; deliverable rows show an invitation outcome (observed at the dev transport)",
+ "evidence": "the import response + the transport capture + the reveal screenshot"
+ },
+ {
+ "clause": "policy `temporary` forces a per-row one-time password for EVERY row (no invitations)",
+ "oracle": "api",
+ "verify": "every result row under `temporary` carries a temporaryPassword; no invitation is issued",
+ "evidence": "the import response"
+ },
+ {
+ "clause": "an imported user can sign in: the one-time password (temporary/auto-fallback) or the invitation-set password authenticates via email+password",
+ "oracle": "api",
+ "verify": "POST /api/v1/auth/sign-in/email for an imported user with the revealed/one-time credential returns a session",
+ "evidence": "the sign-in trace"
+ },
+ {
+ "clause": "re-import is idempotent on upsert: re-running the same CSV (matchBy email) updates existing users rather than creating duplicates",
+ "oracle": "api",
+ "verify": "sys_user count for the imported emails is unchanged after the second run; the summary shows updated/skipped, not created",
+ "evidence": "the two import summaries + the sys_user read"
+ },
+ {
+ "clause": "one-time passwords are never persisted: they exist only in the import response/result step, absent from any later user read or audit row",
+ "oracle": "api",
+ "verify": "a follow-up read of an imported user (and any audit row) contains no plaintext temporary password",
+ "evidence": "the follow-up reads"
+ },
+ {
+ "clause": "the import is platform-admin-gated: a non-admin's direct POST /api/v1/auth/admin/import-users is refused server-side",
+ "oracle": "api",
+ "verify": "the forged non-admin request returns non-2xx and no users are created",
+ "evidence": "the refusal + the unchanged sys_user read"
+ }
+ ],
+ "negative": [
+ "a `temporary` import that leaves any row WITHOUT a one-time credential (so the user can never sign in) is a FAIL",
+ "a one-time password persisted anywhere server-side (user row, audit log) is a security FAIL — it is response-only",
+ "a re-import that DUPLICATES users instead of upserting on the match key is a FAIL",
+ "a non-admin succeeding at import-users is a privilege FAIL — apply RUNNER rule 7"
+ ],
+ "variants": [
+ "auto (default: invite reachable, one-time-password fallback for unreachable)",
+ "temporary (per-row one-time password for every row)",
+ "invite (force an invitation for every row; unreachable rows fail)",
+ "none (identity only — user sets a password later via OTP / magic link / reset)"
+ ],
+ "traps": ["wrong-persona", "seed-data-thin"],
+ "source": [
+ "objectui packages/app-shell/src/views/identityImport.ts (IdentityPasswordPolicy 'auto'|'none'|'invite'|'temporary'; wraps ImportWizard onto POST /api/v1/auth/admin/import-users; ≤500-row batches; one-time passwords response-only, never persisted; upsert idempotent on email/phone)",
+ "packages/plugins/plugin-auth/src/admin-user-endpoints.ts (POST /api/v1/auth/admin/import-users — platform-admin-gated login-capable account creation; explicit-password/generatePassword resolution)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-08", "change": "new item: admin CSV identity import with password-policy matrix (auto/temporary/invite/none), imported-user sign-in, upsert idempotency, response-only one-time passwords, non-admin denied, grounded in objectui identityImport.ts + admin-user-endpoints.ts (PENDING-GAPS §G)", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ }
+ ]
+}
diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json
new file mode 100644
index 0000000000..83bc2b0665
--- /dev/null
+++ b/docs/qa/platform-checklist/areas/integration-system.json
@@ -0,0 +1,1199 @@
+{
+ "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md. FIXTURE MAP for this area (all in-repo, CI-deterministic): the showcase ships four declarative connectors (examples/app-showcase/src/system/connectors/index.ts — rest/openapi/mcp provider-bound instances + one enabled:false catalog descriptor), an outbound webhook (src/automation/webhooks — shipped inactive), a cron job (src/automation/jobs — showcase_health_sweep), an email template (src/system/emails — showcase_task_done_email) and two notify flows (src/automation/flows).",
+ "area": "integration-system",
+ "title": "Integration & system services — connectors, webhooks, jobs, email templates, notifications",
+ "items": [
+ {
+ "id": "integration-system.connector-declarative-boot",
+ "title": "Provider-bound declarative connectors materialize at boot for every installed provider kind (rest/openapi/mcp), list with origin:'declarative' state:'ready', and dispatch end-to-end from connector_action",
+ "since": "v15.1",
+ "status": "active",
+ "revision": 2,
+ "priority": "P1",
+ "surface": "api",
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the three provider-bound showcase instances (examples/app-showcase/src/system/connectors/index.ts): showcase_status_api (provider:'rest', providerConfig.baseUrl self-pointing), showcase_status_openapi (provider:'openapi', file-path spec), showcase_mcp_tools (provider:'mcp', stdio fixture scripts/mcp-fixture.mjs) — #2994/#3062/#3056",
+ "the provider factories installed in objectstack.config.ts: ConnectorRestPlugin, ConnectorOpenApiPlugin, ConnectorMcpPlugin({ declarativeStdio: ['node'] })",
+ "the two dispatch flows: ShowcaseDeclarativeConnectorPingFlow (rest getHealth) and ShowcaseMcpConnectorEchoFlow (mcp echo_upper) — examples/app-showcase/src/automation/flows/index.ts"
+ ]
+ },
+ "variants": [
+ "provider: rest — providerConfig { baseUrl }, actions hand-derived by the factory",
+ "provider: openapi — providerConfig { spec, baseUrl? }; actions derived from the OpenAPI document's operations (getHealth)",
+ "provider: mcp — providerConfig { transport }; actions derived from the upstream's tools/list (echo_upper)",
+ "no provider — catalog descriptor (showcase_erp_catalog): registered as metadata only, NEVER in the runtime registry (#2612)"
+ ],
+ "steps": [
+ "boot the showcase; capture the boot log lines 'Connector registered: (… origin: declarative)'",
+ "GET /api/v1/automation/connectors (route ledger: automation.listConnectors); the body is { connectors, total }",
+ "for each of the three instances, record its descriptor: origin, state, and the actions[] array (key/label/inputSchema/outputSchema/effect)",
+ "confirm showcase_erp_catalog is ABSENT from the runtime listing (it has no provider — descriptor only)",
+ "POST /api/v1/automation/showcase_declarative_connector_ping/trigger and .../showcase_mcp_connector_echo/trigger (automation.execute); read each run via GET /automation/:name/runs",
+ "author the DeclarativeConnectorEntrySchema negatives in a scratch package and build each: (a) providerConfig with NO provider, (b) provider-bound entry that also authors actions[], (c) provider-bound entry with inline authentication { type: 'api-key', … }, (d) provider: 'no_such_provider'; capture all four rejections",
+ "disable one declaration (enabled:false) and reboot; re-list"
+ ],
+ "acceptance": [
+ {
+ "clause": "all three provider variants appear in GET /automation/connectors with origin:'declarative' and state:'ready' — one clause verdict PER variant, none inferred from a sibling",
+ "oracle": "api",
+ "verify": "the listing contains showcase_status_api, showcase_status_openapi, showcase_mcp_tools each with origin 'declarative' + state 'ready' (engine.getConnectorDescriptors shape)",
+ "evidence": "the three descriptors, keyed by provider variant"
+ },
+ {
+ "clause": "actions are DERIVED from each provider's upstream, not authored: the mcp instance's action set equals the fixture server's tools/list (echo_upper), the openapi instance's equals the document's operations (getHealth)",
+ "oracle": "api",
+ "verify": "descriptor actions[] against scripts/mcp-fixture.mjs and src/system/connectors/status-openapi.json",
+ "evidence": "the action arrays next to their upstream sources"
+ },
+ {
+ "clause": "a materialized instance is DISPATCHABLE, indistinguishable from a hand-registered connector: both flows complete and their connector_action outputs land in flow variables (rest ping returns the health payload; mcp echo returns the uppercased string)",
+ "oracle": "api",
+ "verify": "the two triggered runs succeed; run output/variables carry the upstream results",
+ "evidence": "the two run records"
+ },
+ {
+ "clause": "the authoring gate rejects each malformed entry with its located ADR-0097 message: providerConfig-without-provider ('`providerConfig` requires a `provider` …'), instance-authored actions ('must not author `actions` — the … provider derives them from the upstream at boot'), inline secrets ('must not inline secrets via `authentication`; reference credentials with `auth: { type, credentialRef }`')",
+ "oracle": "build",
+ "verify": "the three superRefine rejections match packages/spec/src/integration/connector.zod.ts DeclarativeConnectorEntrySchema verbatim on the quoted fragments",
+ "evidence": "the three error texts"
+ },
+ {
+ "clause": "an unknown provider key is a HARD BOOT ERROR (fail loudly, ADR-0097 §Decision) — never a silently-dead connector",
+ "oracle": "log",
+ "verify": "boot with provider:'no_such_provider' aborts with an error naming the provider",
+ "evidence": "the fatal boot output"
+ },
+ {
+ "clause": "materialization tracks the metadata: a disabled/removed declaration is gone from the listing after reboot (and torn down on reload) — it does not fossilize",
+ "oracle": "api",
+ "verify": "post-disable listing omits the instance",
+ "evidence": "the before/after listings"
+ }
+ ],
+ "negative": [
+ "the catalog descriptor (showcase_erp_catalog) appearing in the RUNTIME listing is a FAIL — descriptor vs instance is the #2612 boundary this whole surface is built on",
+ "any of the four malformed-entry builds passing clean is a FAIL (silent acceptance of an unmaterializable declaration)"
+ ],
+ "traps": [
+ "stale-dist"
+ ],
+ "automated": {
+ "kind": "e2e",
+ "ref": "packages/qa/dogfood/test/showcase-declarative-mcp.dogfood.test.ts"
+ },
+ "source": [
+ "docs/plans/release-15.1-test-plan.md §B1 (#2994/#3062)",
+ "packages/spec/src/integration/connector.zod.ts (provider/providerConfig/auth keys + DeclarativeConnectorEntrySchema cross-field rejections, exact messages)",
+ "packages/spec/src/integration/connector-provider.ts (factory contract; adopt-declared-name; throw ⇒ hard boot error)",
+ "packages/services/service-automation/src/engine.ts (registerConnector origin 'declarative'; getConnectorDescriptors shape)",
+ "packages/runtime/src/route-ledger.ts (GET /automation/connectors, POST /automation/:name/trigger, GET /automation/:name/runs)",
+ "examples/app-showcase/src/system/connectors/index.ts + src/automation/flows/index.ts (the fixtures)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial import from the 15.1 plan §B — connectors had no checklist coverage",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "integration-system.connector-degraded-recovery",
+ "title": "An unreachable connector upstream degrades that ONE instance (CONNECTOR_UPSTREAM_UNAVAILABLE) instead of failing boot, retries on 5s→300s backoff, and recovers atomically — while config faults stay boot-fatal",
+ "since": "v15.1",
+ "status": "active",
+ "revision": 2,
+ "priority": "P1",
+ "surface": "api",
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the showcase_mcp_tools stdio instance — break its upstream deterministically by pointing providerConfig.transport.args at a nonexistent script (the exact experiment its own fixture comment prescribes, examples/app-showcase/src/system/connectors/index.ts)",
+ "a config-fault twin for the both-sides check: the same entry with an invalid providerConfig shape (e.g. transport.kind: 'carrier_pigeon')"
+ ]
+ },
+ "steps": [
+ "boot with the broken upstream; time the boot and capture the '[Automation] connector … registered DEGRADED' warn with its degradedReason",
+ "GET /api/v1/automation/connectors; record the husk descriptor (state, degradedReason, actions)",
+ "POST the flow that dispatches it (showcase_mcp_connector_echo trigger); capture the dispatch failure",
+ "watch the log through at least three retry cycles; record the intervals",
+ "restore the upstream (fix the script path is a config CHANGE — instead restore by making the original path valid again, e.g. re-adding the file) WITHOUT restarting; wait one backoff cycle; re-read the descriptor",
+ "separately, boot the config-fault twin and capture that boot's outcome",
+ "trigger a metadata:reloaded reconcile (touch/save the entry) and confirm it retries immediately and resets the backoff"
+ ],
+ "acceptance": [
+ {
+ "clause": "boot COMPLETES on the dead upstream — the instance lands as a degraded husk: descriptor state:'degraded' with a degradedReason quoting the operational failure; no actions, and it still appears in GET /automation/connectors (visible, not vanished)",
+ "oracle": "api",
+ "verify": "boot exit + the husk descriptor (engine.registerDegradedConnector: state 'degraded', empty handlers, stored degradedReason)",
+ "evidence": "boot log + descriptor"
+ },
+ {
+ "clause": "the degrade path is taken ONLY for errors carrying code CONNECTOR_UPSTREAM_UNAVAILABLE (structural check, not instanceof); a configuration fault (invalid providerConfig) remains FATAL at boot — both sides of the #3017 classification",
+ "oracle": "log",
+ "verify": "the broken-upstream boot degrades; the config-fault twin's boot aborts with the factory's validation error (connector-provider-errors.ts contract)",
+ "evidence": "the two boot outcomes side by side"
+ },
+ {
+ "clause": "dispatching a degraded instance fails FAST with a pointed connector-unavailable error that quotes the stored degradedReason — not a timeout hang, and distinguishable from 'no such connector/action'",
+ "oracle": "api",
+ "verify": "the connector_action step errors promptly; its message carries the degradedReason (engine.getConnectorDegradedReason feeds the refusal)",
+ "evidence": "the failed run record"
+ },
+ {
+ "clause": "retries back off from DECLARATIVE_RETRY_BASE_MS (5s) doubling to the 300s ceiling — no hot loop against the dead upstream",
+ "oracle": "log",
+ "verify": "observed retry intervals ≈ 5s/10s/20s… (service-automation/src/plugin.ts constants); a config edit or reload reconcile resets the backoff and retries immediately",
+ "evidence": "timestamped log excerpt"
+ },
+ {
+ "clause": "recovery is ATOMIC and restart-free: once the upstream is reachable a retry swaps the live instance in — descriptor flips to state:'ready' with the derived actions, and the previously-failing flow dispatch now succeeds",
+ "oracle": "api",
+ "verify": "post-recovery descriptor + a successful echo run, all without a server restart",
+ "evidence": "the recovered descriptor + run record"
+ }
+ ],
+ "negative": [
+ "a dead upstream taking the WHOLE boot down is the outage-amplification #3017 exists to prevent — FAIL",
+ "a degraded instance silently disappearing from GET /automation/connectors is a FAIL (operators must be able to SEE the husk and its reason)"
+ ],
+ "traps": [
+ "stale-dist"
+ ],
+ "source": [
+ "docs/plans/release-15.1-test-plan.md §B3 (#3049)",
+ "packages/spec/src/integration/connector-provider-errors.ts (CONNECTOR_UPSTREAM_UNAVAILABLE marker; structural isConnectorUpstreamUnavailable)",
+ "packages/services/service-automation/src/plugin.ts (DECLARATIVE_RETRY_BASE_MS=5000 / DECLARATIVE_RETRY_MAX_MS=300000; degradedInstances backoff + reload reconcile; boot fatal vs reload skip)",
+ "packages/services/service-automation/src/engine.ts (registerDegradedConnector; getConnectorDegradedReason feeding the dispatch refusal)",
+ "packages/services/service-automation/src/connector-degrade-cause.test.ts + degraded-register-cause.test.ts (unit pins)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial import from the 15.1 plan §B3",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "integration-system.connector-stdio-default-deny",
+ "title": "Declarative stdio connector transports are denied by default; the host allowlists exact commands (declarativeStdio) — and the deny is a config fault (boot fatal), not a degrade",
+ "since": "v15.1",
+ "status": "active",
+ "revision": 2,
+ "priority": "P1",
+ "surface": "cli",
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the showcase_mcp_tools stdio instance (command 'node') + the host opt-in `new ConnectorMcpPlugin({ declarativeStdio: ['node'] })` in examples/app-showcase/objectstack.config.ts — removing that option IS the deny fixture (the connector file's own comment says 'try it')"
+ ]
+ },
+ "variants": [
+ "policy absent/false → deny every declarative stdio transport (default)",
+ "policy ['node'] with command 'node' → allowed (strict string equality)",
+ "policy ['node'] with a non-matching command (e.g. 'python3') → denied, allowlist quoted",
+ "http transport → never subject to the stdio policy (control)"
+ ],
+ "steps": [
+ "boot the stock showcase (allowlisted): confirm showcase_mcp_tools materializes state:'ready'",
+ "remove the declarativeStdio option from ConnectorMcpPlugin in objectstack.config.ts; boot; capture the failure verbatim",
+ "restore the option but change the connector's command to one NOT in the allowlist (e.g. 'python3' with the same script); boot; capture that failure",
+ "restore fully; author a scratch mcp instance with an HTTP transport pointing at any URL and confirm the stdio policy does not touch it (it may degrade on unreachability — that is the OTHER item)",
+ "GET /api/v1/automation/connectors after the allowlisted boot"
+ ],
+ "acceptance": [
+ {
+ "clause": "the non-opted-in stdio instance refuses with the message naming the mechanism: 'declares a stdio transport (command \\'node\\'), but declarative stdio transports are disabled by default — a stdio transport launches a local process …' (connector-mcp provider, #3055)",
+ "oracle": "log",
+ "verify": "boot output contains the deny message from packages/connectors/connector-mcp/src/mcp-provider.ts; the fixture process is never spawned",
+ "evidence": "the refusal line"
+ },
+ {
+ "clause": "an allowlist MISMATCH is refused quoting both the offending command and the configured allowlist: \"stdio transport with command 'python3', which is not in the host's declarativeStdio allowlist [node]\" — equality is strict, no prefix/glob creep",
+ "oracle": "log",
+ "verify": "the mismatch boot's error text carries command + allowlist",
+ "evidence": "the refusal line"
+ },
+ {
+ "clause": "the deny is classified as a CONFIGURATION fault: boot-fatal at start (and a skipped entry on reload) — NOT a degraded husk retrying forever toward a process the host never authorized",
+ "oracle": "log",
+ "verify": "the denied boots abort; no 'registered DEGRADED' line and no retry loop for the denied instance",
+ "evidence": "boot output"
+ },
+ {
+ "clause": "the allowlisted boot materializes the instance normally (state:'ready', echo_upper action derived) — the opt-in works and stays scoped to the exact command",
+ "oracle": "api",
+ "verify": "GET /automation/connectors shows showcase_mcp_tools ready on the stock config",
+ "evidence": "the descriptor"
+ }
+ ],
+ "negative": [
+ "absence of ANY allowlist config silently spawning a subprocess from metadata is the security failure #3055 closed — a spawned fixture process on the deny boot is a P0-severity FAIL regardless of this item's priority",
+ "the deny surfacing as state:'degraded' with backoff retries is a FAIL — an unauthorized command must not be retried into existence"
+ ],
+ "automated": {
+ "kind": "unit",
+ "ref": "packages/connectors/connector-mcp/src/mcp-provider.test.ts"
+ },
+ "source": [
+ "docs/plans/release-15.1-test-plan.md §B4 (#3059)",
+ "packages/connectors/connector-mcp/src/mcp-provider.ts (#3055 policy: default deny, strict-equality allowlist, both messages)",
+ "examples/app-showcase/objectstack.config.ts (declarativeStdio: ['node'] opt-in) + src/system/connectors/index.ts (the 'remove that option and boot fails loudly — try it' fixture note)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial import from the 15.1 plan §B4",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "integration-system.connector-spec-path-no-escape",
+ "title": "Connector openapi spec file refs resolve package-relative only: './…' works, absolute and '../' escapes reject with the confinement error, missing files fail loudly",
+ "since": "v15.1",
+ "status": "active",
+ "revision": 2,
+ "priority": "P2",
+ "surface": "build",
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the shipped file-path fixture: showcase_status_openapi with providerConfig.spec './src/system/connectors/status-openapi.json' (#3016) — the happy path is stock",
+ "scratch copies of that entry for the three rejection probes (absolute path, '../' escape, missing file)"
+ ]
+ },
+ "variants": [
+ "spec: inline OpenAPI document object (no file read)",
+ "spec: package-relative file path './…' (resolved against packageRoot, confined)",
+ "spec: http(s) URL (fetched; unreachability is the degrade item's territory)",
+ "rejection: absolute path (/etc/… or C:\\…)",
+ "rejection: '../'-escaping relative path",
+ "rejection: missing/unreadable file"
+ ],
+ "steps": [
+ "boot the stock showcase; confirm showcase_status_openapi materialized with the spec-derived getHealth action",
+ "author scratch entries and boot each: spec '/etc/hostname' (absolute), spec '../../outside.json' (escape), spec './does-not-exist.json' (missing); capture each error verbatim",
+ "author one with a nested traversal that RESOLVES inside the root after normalization (e.g. './src/../src/system/connectors/status-openapi.json') and record whether it is accepted — the guard rejects on the RESOLVED path, not on the substring",
+ "verify boot-vs-reload policy: repeat the missing-file probe via a metadata reload and confirm it is skipped-with-log instead of fatal"
+ ],
+ "acceptance": [
+ {
+ "clause": "the package-relative path resolves and the connector's actions derive from the document (getHealth on the descriptor; dispatch covered by connector-declarative-boot)",
+ "oracle": "api",
+ "verify": "descriptor carries the spec-derived action set on the stock boot",
+ "evidence": "descriptor read"
+ },
+ {
+ "clause": "an absolute path is rejected with the exact guard: \"package file ref '' is absolute — file refs must be relative to the declaring stack/package root.\" — including Windows drive-letter forms",
+ "oracle": "build",
+ "verify": "boot error text matches createPackageFileLoader (packages/services/service-automation/src/plugin.ts)",
+ "evidence": "the error"
+ },
+ {
+ "clause": "a path escaping the root after RESOLUTION is rejected: \"package file ref '
' escapes the stack/package root — reads are confined to ''.\" — while an inside-resolving './a/../b' form passes (the check is on the resolved path)",
+ "oracle": "build",
+ "verify": "the '../' probe errors with the confinement text; the normalized-inside probe boots",
+ "evidence": "the two outcomes"
+ },
+ {
+ "clause": "a missing/unreadable file fails LOUDLY with the resolved path in the message ('could not be read (resolved to …)') — boot-fatal at start, skipped-with-log on reload, per the ADR-0097 reconcile policy",
+ "oracle": "build",
+ "verify": "the missing-file boot aborts; the reload path logs + skips",
+ "evidence": "both captures"
+ }
+ ],
+ "negative": [
+ "any read landing OUTSIDE the declaring package root is a security FAIL whatever the error text — the confinement, not the message, is the contract",
+ "a missing spec producing a silently-actionless connector (instead of the loud failure) is a FAIL"
+ ],
+ "source": [
+ "docs/plans/release-15.1-test-plan.md §B5 (#3024)",
+ "packages/services/service-automation/src/plugin.ts (createPackageFileLoader — all three exact error strings, #3016)",
+ "packages/spec/src/integration/connector.zod.ts (providerConfig.spec three-form doc) + connector-provider.ts (loadPackageFile contract incl. host-without-fs behavior)",
+ "examples/app-showcase/src/system/connectors/index.ts (showcase_status_openapi — the shipped happy-path fixture)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial import from the 15.1 plan §B5",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "integration-system.connector-descriptor-audit",
+ "title": "Descriptor-only connector contracts are audited at boot: declared-with-actions-but-unregistered warns with names + remedy; enabled:false is the deliberate, quiet catalog opt-out",
+ "since": "v15.1",
+ "status": "active",
+ "revision": 2,
+ "priority": "P2",
+ "surface": "cli",
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_erp_catalog — the shipped enabled:false catalog descriptor WITH actions (get_invoice, post_journal_entry): the stock boot is the quiet side of this gate",
+ "a scratch copy of it with enabled:true (and no provider) for the loud side"
+ ]
+ },
+ "steps": [
+ "boot the stock showcase and grep the full boot log for the inert-connector audit warning — there must be NONE (the only actions-bearing descriptor is enabled:false)",
+ "add the scratch enabled:true descriptor (actions, no provider, no plugin registration); boot; capture the warning verbatim",
+ "add a same-named RUNTIME registration for it (any connector plugin) and boot again — the audit must go quiet because the name is now live",
+ "flip the scratch entry back to enabled:false; boot; confirm quiet"
+ ],
+ "acceptance": [
+ {
+ "clause": "the loud side names names and prescribes the fix: '[Automation] N declarative connector(s) declare actions but are not registered in the connector registry — the connector_action node cannot dispatch them: . … Install/instantiate the matching connector plugin, or mark a deliberate catalog-only entry with `enabled: false` to silence this warning.' (PD#10: declared ≠ delivered surfaces loudly)",
+ "oracle": "log",
+ "verify": "the warning matches auditDeclaredConnectors (packages/services/service-automation/src/plugin.ts) and lists the scratch connector's name",
+ "evidence": "log excerpt"
+ },
+ {
+ "clause": "all three quiet conditions are individually verified: (a) enabled:false descriptor — quiet; (b) descriptor with a same-name runtime registration — quiet; (c) provider-bound instances — never audited by this gate (they materialize instead)",
+ "oracle": "log",
+ "verify": "no audit warning on the stock boot, on the registered boot, or for the three provider-bound showcase instances",
+ "evidence": "the grepped boot logs per condition"
+ },
+ {
+ "clause": "the audit re-runs on metadata reload, not only at boot — enabling the scratch descriptor at runtime surfaces the warning without a restart",
+ "oracle": "log",
+ "verify": "the warn appears after the reload reconcile (plugin.ts wires auditDeclaredConnectors on both paths)",
+ "evidence": "timestamped log"
+ }
+ ],
+ "negative": [
+ "a stock showcase boot emitting the inert-connector warning is a FAIL — either the shipped catalog descriptor lost its enabled:false or the audit's silencing contract broke",
+ "the warning firing for a provider-bound instance is a FAIL (it is an instance declaration, not a descriptor — #2977/ADR-0097 carve-out in findInertDeclaredConnectors)"
+ ],
+ "automated": {
+ "kind": "unit",
+ "ref": "packages/services/service-automation/src/connector-descriptor-audit.test.ts"
+ },
+ "source": [
+ "docs/plans/release-15.1-test-plan.md §B6 (#2985)",
+ "packages/services/service-automation/src/plugin.ts (auditDeclaredConnectors — exact warning text; boot + reload wiring)",
+ "packages/spec/src/integration/connector.zod.ts (enabled:false = deliberate catalog descriptor, #2612)",
+ "examples/app-showcase/src/system/connectors/index.ts (showcase_erp_catalog — the shipped quiet fixture)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial import from the 15.1 plan §B6",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "integration-system.flow-connector-picker",
+ "title": "The flow designer's connector picker mirrors GET /automation/connectors: same instances, declarative ones annotated, actions and their input schemas offered per pick",
+ "since": "v15.1",
+ "status": "active",
+ "revision": 2,
+ "priority": "P2",
+ "surface": "browser",
+ "personas": [
+ "admin"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the stock showcase connector registry: three declarative instances + the plugin-registered rest/slack connectors from objectstack.config.ts (so the picker has BOTH origins to distinguish)"
+ ]
+ },
+ "steps": [
+ "before the browser: GET /api/v1/automation/connectors and record { connectors, total } — this is the ground truth the picker must mirror",
+ "in Studio, open a flow (e.g. showcase_declarative_connector_ping) and add/select a connector_action node",
+ "open the connector picker; screenshot; only AFTER the screenshot, read the DOM list",
+ "pick showcase_mcp_tools; record which actions are offered and screenshot the action + input form for echo_upper",
+ "pick a plugin-registered connector and compare its presentation with the declarative one's annotation"
+ ],
+ "acceptance": [
+ {
+ "clause": "the picker lists exactly the instances the API reports — no extras (a picker inventing catalog descriptors would let authors wire undispatchable nodes), none missing",
+ "oracle": "screenshot",
+ "verify": "picker screenshot cross-checked name-by-name against the pre-captured listing",
+ "evidence": "screenshot + API read, diffed"
+ },
+ {
+ "clause": "declarative instances carry their annotation (origin distinguishable from plugin-registered), per the descriptor's origin field the API serves",
+ "oracle": "screenshot",
+ "verify": "the three declarative entries are visually marked; the plugin rest/slack ones are not",
+ "evidence": "annotated screenshot"
+ },
+ {
+ "clause": "picking an instance offers its DERIVED actions with their input schemas (echo_upper for the mcp instance) — the ADR-0022 descriptor pipeline reaches the designer end-to-end",
+ "oracle": "dom",
+ "verify": "action list + input fields match the descriptor's actions[].inputSchema, read only after the screenshot confirmed render",
+ "evidence": "screenshot + DOM excerpt"
+ }
+ ],
+ "negative": [
+ "a picker entry for a connector the API does not list (or vice versa) is a FAIL — the network response is the authority, not the panel"
+ ],
+ "traps": [
+ "stale-console-bundle",
+ "hydration-race",
+ "wrong-panel"
+ ],
+ "source": [
+ "docs/plans/release-15.1-test-plan.md §B2 (objectui#2563)",
+ "packages/services/service-automation/src/engine.ts (getConnectorDescriptors — the designer-facing shape incl. origin + actions[].inputSchema, ADR-0022)",
+ "packages/runtime/src/route-ledger.ts (GET /automation/connectors)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial import from the 15.1 plan §B2",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "integration-system.webhook-lifecycle",
+ "title": "Outbound webhooks materialize (spec object→object_name, isActive→active), fire per trigger variant through the sys_http_delivery outbox with HMAC + timeout honored, reject retired trigger kinds, and never clobber admin edits",
+ "since": "v15",
+ "status": "active",
+ "revision": 3,
+ "priority": "P1",
+ "surface": "mixed",
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "a reachable webhook receiver (local echo server on the run's own port range) — point the shipped showcase_task_changed row at it, or author a scratch webhook",
+ "the shipped fixture: showcase_task_changed (object showcase_task, triggers create/update/delete, isActive:false ON PURPOSE — flipping it active in Setup is part of the test, examples/app-showcase/src/automation/webhooks/index.ts)",
+ "a predicate multi-write path for the bulk variants (update/delete with multi:true on showcase_task)"
+ ]
+ },
+ "variants": [
+ "trigger: create (data.record.created → per-record payload with recordId)",
+ "trigger: update (data.record.updated)",
+ "trigger: delete (data.record.deleted)",
+ "trigger: bulk_update (aggregate data.records.updated → { object, matched }, NO recordId/body — #4639)",
+ "trigger: bulk_delete (aggregate data.records.deleted, same shape)",
+ "retired: undelete — no event source exists (#3196); parse-rejected",
+ "retired: api (manual fire) — no fire path exists (#3196); parse-rejected"
+ ],
+ "steps": [
+ "boot the showcase; verify the materializer bridge: read sys_webhook over /api/v1/data and locate showcase_task_changed with object_name:'showcase_task', active:false, managed_by:'package', and the envelope in definition_json",
+ "in Setup → Integrations → Webhooks flip the row active and point url at the local receiver (an admin edit — it must stamp customized)",
+ "create, update, then delete a showcase_task; capture the three deliveries at the receiver (headers incl. the HMAC signature when a secret is set, body incl. recordId)",
+ "run a predicate multi-update and multi-delete (multi:true) matching several rows; capture the bulk deliveries and their { object, matched } shape",
+ "read sys_http_delivery over /api/v1/data: one row per delivery with status/attempts/lastStatusCode",
+ "kill the receiver and mutate again; re-read the delivery row through its retry/failure states",
+ "author a scratch webhook with triggers:['undelete'] and one with ['api']; build both and capture the parse errors",
+ "redeploy/reboot and confirm the admin-edited row survived re-seed (customized rows are never clobbered)"
+ ],
+ "acceptance": [
+ {
+ "clause": "authoring is LIVE, not a no-op: the declared webhook materializes into the sys_webhook row the dispatcher reads, with the two documented remaps (object→object_name, isActive→active) and the envelope in definition_json (#3461/#3489)",
+ "oracle": "api",
+ "verify": "the sys_webhook row fields against bootstrap-declared-webhooks mapWebhookToRow",
+ "evidence": "the row read"
+ },
+ {
+ "clause": "each per-record trigger variant delivers exactly its event: create/update/delete each produce one receiver hit whose payload names the event and carries the recordId — verified per-variant, none inferred",
+ "oracle": "network",
+ "verify": "the three captured requests, keyed by variant",
+ "evidence": "receiver logs"
+ },
+ {
+ "clause": "the bulk pair delivers the AGGREGATE shape — { object, matched } with no recordId and no record body — and only to webhooks that opted into bulk_update/bulk_delete; per-record subscribers do NOT receive a fabricated per-record event for a predicate write (#4639/#4626)",
+ "oracle": "network",
+ "verify": "bulk delivery bodies + absence of per-record deliveries for the same predicate write on a create/update/delete-only subscription",
+ "evidence": "receiver logs for both subscriptions"
+ },
+ {
+ "clause": "delivery mechanics honor the authored envelope: custom headers attached, HMAC signature present when secret is set, timeoutMs applied — and every attempt is durably observable as a sys_http_delivery row (status pending/in_flight/success/failed/dead, attempts, lastStatusCode)",
+ "oracle": "api",
+ "verify": "receiver-side headers + the outbox rows over the data API",
+ "evidence": "headers + delivery rows"
+ },
+ {
+ "clause": "a retired trigger kind fails at parse with the enum rejection — WebhookTriggerType is exactly [create, update, delete, bulk_update, bulk_delete]; undelete/api never register silently dead (the #3196 enforce-or-remove gate the #3358 sweep verified)",
+ "oracle": "build",
+ "verify": "both scratch builds error on the trigger value",
+ "evidence": "the two build errors"
+ },
+ {
+ "clause": "seed-not-clobber: the admin-edited row (customized:true) survives redeploys — the deactivation/receiver-URL edit is still there after reboot",
+ "oracle": "api",
+ "verify": "post-reboot sys_webhook row keeps the admin's values (bootstrap-declared-webhooks.ts:132-145)",
+ "evidence": "before/after row reads"
+ }
+ ],
+ "negative": [
+ "an unreachable receiver must surface as failed/dead sys_http_delivery rows (and retry per the outbox schedule) — a dropped delivery with no durable trace is a FAIL",
+ "a stored row whose triggers contain an unknown value must be dropped LOUDLY by the dispatcher (the #3196 drift-guard warn: 'dead while looking armed in Setup') — silent armed-looking deadness is a FAIL",
+ "connector-attached `webhooks`/`triggers` (integration/connector.zod.ts) are NOT dispatched by anything (#3197, said in-schema) — a run must not tick them as live, and a delivery appearing from one would be a spec-contract FAIL"
+ ],
+ "traps": [
+ "seed-data-thin",
+ "stale-dist"
+ ],
+ "automated": {
+ "kind": "e2e",
+ "ref": "packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts"
+ },
+ "source": [
+ "packages/spec/src/automation/webhook.zod.ts (WebhookTriggerType enum + why undelete/api are absent; materialization contract; strict shape #4001)",
+ "packages/spec/liveness/webhook.json (all 11 props live via the #3489 bridge; per-prop line refs)",
+ "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts + auto-enqueuer.ts (remaps; trigger→event mapping incl. the #4639 bulk pair; #3196 unknown-trigger warn; seed-not-clobber)",
+ "packages/services/service-messaging/src/http-outbox.ts (delivery statuses, attempts, redeliver contract) + plugin-webhooks/webhook-outbox-plugin.ts (sys_http_delivery nav)",
+ "examples/app-showcase/src/automation/webhooks/index.ts (the shipped inactive fixture and its activation story)",
+ "#3358 §9 (webhook undelete/api trigger removal gate)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — webhook kind had no checklist coverage",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 3,
+ "date": "2026-08-08",
+ "change": "pinned enumSource for the variants-freshness ratchet — spec enum drift is caught by the manual check on this item directly",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ],
+ "enumSource": {
+ "file": "packages/spec/src/automation/webhook.zod.ts",
+ "export": "WebhookTriggerType",
+ "expect": 5
+ }
+ },
+ {
+ "id": "integration-system.job-scheduled-run",
+ "title": "Job metadata schedules through every ScheduleSchema variant, executes with retry/timeout enforced, records sys_job/sys_job_run truth, and refuses the closed doors (id key, runtime create, missing handler)",
+ "since": "v15",
+ "status": "active",
+ "revision": 2,
+ "priority": "P1",
+ "surface": "mixed",
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the shipped cron fixture: showcase_health_sweep (schedule cron '0 1 * * *' UTC, handler 'sweepProjectHealth' registered in defineStack({ functions }), retryPolicy { maxRetries: 2, backoffMs: 5000, backoffMultiplier: 2 }, timeout 300000 — examples/app-showcase/src/automation/jobs/index.ts; the handler-missing regression here was #4774/#4888)",
+ "scratch jobs in a writable package for the interval / once / failing / timing-out probes (near-term schedules so the run observes real executions)"
+ ]
+ },
+ "variants": [
+ "schedule { type: 'cron', expression, timezone (default UTC) } — cron-job-adapter.ts:76-77",
+ "schedule { type: 'interval', intervalMs } — cron-job-adapter.ts:82",
+ "schedule { type: 'once', at: ISO datetime } — cron-job-adapter.ts:87",
+ "execution status: success | failed | timeout (JobExecutionStatus; 'running' is the in-flight state)",
+ "rejection: job.id (retired 17.0.0, #4667 — guidance-carrying parse error)",
+ "rejection: unknown key (strictObject #4001; aliases cron/interval→schedule, fn→handler)"
+ ],
+ "steps": [
+ "boot the showcase; capture the AppPlugin job registration path for showcase_health_sweep (bundle jobs → IJobService.schedule on kernel:ready)",
+ "read sys_job over /api/v1/data: the row is keyed by NAME (the adapter mints its own row id) with the schedule persisted",
+ "author three scratch jobs (interval ~5s, once at now+1min, and a cron) with observable side effects; boot; let each fire; read sys_job_run rows and sys_job.last_run_at/last_status/run_count",
+ "author a deliberately-throwing job with retryPolicy { maxRetries: 2, backoffMs: 1000, backoffMultiplier: 2 }; let it exhaust; capture run rows + failure_count",
+ "author a job whose handler sleeps past a small `timeout`; capture the run's status",
+ "author a job with enabled:false and one whose handler string names NO registered function; boot; capture how each is skipped",
+ "author a job carrying `id: 'x'` and one with a stray key; build both; capture the errors",
+ "attempt to create a job at runtime through the meta door and capture the refusal (allowRuntimeCreate:false, #4509)"
+ ],
+ "acceptance": [
+ {
+ "clause": "every ScheduleSchema variant actually schedules and fires — cron (with timezone), interval (intervalMs), once (at) — each verified by its OWN sys_job_run row and side effect, not by registration lines alone",
+ "oracle": "api",
+ "verify": "per-variant run rows + the side effect over the data API (adapters honor all three shapes: cron-job-adapter.ts:71-88; db adapter persists them: db-job-adapter.ts:233-245)",
+ "evidence": "run rows + side-effect reads, keyed by variant"
+ },
+ {
+ "clause": "execution truth is durable and name-keyed: sys_job upserts by name; every execution writes a sys_job_run row and bumps last_run_at/last_status/run_count/failure_count",
+ "oracle": "api",
+ "verify": "the counters move with each observed run",
+ "evidence": "before/after row reads"
+ },
+ {
+ "clause": "a failing job retries per the CONVERGED policy — delay = backoffMs × multiplier^(retry-1) up to maxRetries — then records status 'failed' with the error message; a policy-less job gets ONE attempt (maxRetries defaults to 0 since 17.0.0, #4661 — a run asserting 3 default retries is testing the old world)",
+ "oracle": "log",
+ "verify": "attempt count + spacing in the run rows/log for the throwing probe; the no-policy probe shows exactly one attempt",
+ "evidence": "run rows + timestamped log"
+ },
+ {
+ "clause": "an over-`timeout` run is recorded with execution status 'timeout' (the in-flight handler is abandoned, not force-cancelled — as documented), and timeouts COUNT as failures for the retry loop (#3494)",
+ "oracle": "api",
+ "verify": "the timeout probe's run row status",
+ "evidence": "the run row"
+ },
+ {
+ "clause": "the two skip doors are LOUD and distinct: enabled:false skips scheduling at registration; a handler naming no registered function skips with '[AppPlugin] job handler not found in bundle.functions — skipping' — the exact silent-no-op that let the showcase sweep never run for months (#4774/#4888)",
+ "oracle": "log",
+ "verify": "both skip lines present; neither job has sys_job_run rows",
+ "evidence": "log excerpts + absence of runs"
+ },
+ {
+ "clause": "the closed doors refuse loudly: authoring `id` errors with the #4667 prescription ('`job.id` was removed … `name` IS the job's identity everywhere … os migrate meta --from 16'); a stray key errors naming the key (aliases steer cron/interval→schedule, fn→handler); runtime create is refused (allowRuntimeCreate:false — handler strings cannot resolve outside the compiled bundle, #4509)",
+ "oracle": "build",
+ "verify": "the parse errors + the runtime-create refusal",
+ "evidence": "the error texts"
+ }
+ ],
+ "negative": [
+ "a declared job that never runs WITHOUT one of the two loud skip lines is a FAIL against the scheduler, not thin data — 'nothing reports this' was the actual #4774 bug",
+ "a runtime-created job row saving cleanly is a FAIL (the #4509 door is closed precisely because such a job could never run)"
+ ],
+ "traps": [
+ "stale-dist",
+ "seed-data-thin"
+ ],
+ "source": [
+ "packages/spec/src/system/job.zod.ts (ScheduleSchema discriminated union; JOB_ID_RETIRED; retryPolicy/timeout docs incl. the 17.0.0 maxRetries default flip #4661; JobExecutionStatus)",
+ "packages/spec/liveness/job.json (per-prop verdicts + the #4509 closed-door rationale)",
+ "packages/runtime/src/app-plugin.ts:790-855 (registration, enabled/handler skip lines)",
+ "packages/services/service-job/src/cron-job-adapter.ts + db-job-adapter.ts (all three schedule shapes; sys_job/sys_job_run persistence) + run-with-policy.ts (retry/timeout enforcement, #3494)",
+ "examples/app-showcase/src/automation/jobs/index.ts (showcase_health_sweep fixture + its #4774/#4888 history)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — job kind had no checklist coverage",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "integration-system.email-template-render",
+ "title": "Email templates materialize to sys_email_template, resolve (name, locale) with en-US fallback, render {{path}} holes, gate on required variables and active:false, survive admin edits across redeploys — and the raw POST /api/v1/email/send door authenticates, refuses anonymous, and 400s malformed input",
+ "since": "v15",
+ "status": "active",
+ "revision": 3,
+ "priority": "P2",
+ "surface": "mixed",
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the shipped fixture: showcase_task_done_email (category workflow, subject '✅ Task done: {{title}}', variables title required / project optional — examples/app-showcase/src/system/emails/index.ts)",
+ "a capturable email channel (dev transport / log capture — no real SMTP needed)",
+ "a second-locale scratch copy (same name, locale zh-CN) for the i18n-bundle clause"
+ ],
+ "knownGaps": [
+ "no stock example exercises an email template end-to-end; needs a small fixture flow with a notify/email step",
+ "refinement of the gap above (2026-08-07 survey): the TEMPLATE fixture now exists (showcase_task_done_email) and the send path is pinned by packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts, but no shipped FLOW invokes sendTemplate — the flow-driven half still needs the fixture flow, or the send must be driven directly via IEmailService.sendTemplate in the run"
+ ]
+ },
+ "variants": [
+ "locale resolution: exact (name, locale) match / en-US fallback / two-locale bundle picking by recipient locale",
+ "body: bodyHtml + bodyText authored / bodyText omitted (service derives text from HTML — omitted from the ROW, not nulled)",
+ "gate: active:false → TEMPLATE_INACTIVE",
+ "gate: missing required variable → fail-fast (requireVars)",
+ "rejection: unknown key (strictObject; aliases title→subject, content/html→bodyHtml, text→bodyText, from/sender→fromOverride — #5013)",
+ "rejection: name not dotted snake_case"
+ ],
+ "steps": [
+ "boot the showcase; read sys_email_template over /api/v1/data and locate showcase_task_done_email: bodyHtml→body_html remap, variables in variables_json, managed_by:'package'",
+ "drive a send with full data ({ title, project }) through IEmailService.sendTemplate (or the fixture flow once it exists); capture the rendered subject/body at the dev transport",
+ "drive a send MISSING the required `title`; capture the fast failure",
+ "set the row inactive (or author active:false) and send; capture the TEMPLATE_INACTIVE error",
+ "author the zh-CN twin; send to a zh-CN-locale recipient and to an unmatched-locale recipient; capture which row rendered each",
+ "author a template with fromOverride + replyTo and verify both on the outbound message",
+ "edit the template wording as an admin in Studio (stamps customized), redeploy/reboot, and re-read the row",
+ "edit the DECLARED source template and metadata-reload WITHOUT a reboot — the single item re-materializes (email_template is allowRuntimeCreate:true; the plugin subscribes to metadata changes)",
+ "build the two rejection probes (stray key `body`; name 'BadName') and capture the errors",
+ "drive the raw send door POST /api/v1/email/send three ways: (a) authed with a well-formed message { to, subject, bodyHtml } → capture status + the dev-transport landing; (b) anonymous (no session) → capture status; (c) a non-object / malformed body → capture the envelope"
+ ],
+ "acceptance": [
+ {
+ "clause": "the authored template is what actually renders: subject/body substitute every {{path}} hole with the run's values, and the AUTHORED wording (not a built-in or stale copy) reaches the transport — the exact three-break disconnect #4509 closed (engine registration + managed_by stamping + bridge write)",
+ "oracle": "log",
+ "verify": "captured render contains the authored strings with substituted values and zero unresolved {{placeholder}} residue",
+ "evidence": "the captured render"
+ },
+ {
+ "clause": "a send missing a REQUIRED variable fails fast with a located error naming the variable (requireVars, email-service.ts) — never a silently-empty substitution; the optional variable's absence does not fail the send",
+ "oracle": "log",
+ "verify": "the missing-title send errors; a missing-project send renders",
+ "evidence": "the two outcomes"
+ },
+ {
+ "clause": "active:false is a real gate: sendTemplate returns TEMPLATE_INACTIVE (also the withdrawal mechanism — deleting a declared template deactivates rows rather than destroying them)",
+ "oracle": "log",
+ "verify": "the inactive send's error code",
+ "evidence": "the error"
+ },
+ {
+ "clause": "(name, locale) resolution picks the best locale row and falls back to en-US — two rows with one name are an i18n bundle, both reachable by recipient locale",
+ "oracle": "log",
+ "verify": "the zh-CN recipient gets the zh-CN render; the unmatched recipient gets the fallback",
+ "evidence": "the two renders"
+ },
+ {
+ "clause": "fromOverride and replyTo are honored on the outbound message; an omitted bodyText is derived from HTML at send time (and stays absent on the row so re-seeds never blank it)",
+ "oracle": "log",
+ "verify": "outbound headers + the derived text alternative",
+ "evidence": "the captured message"
+ },
+ {
+ "clause": "both provenance protections hold: an admin-edited (customized) row survives redeploys, AND a declared-source edit re-materializes the single item on metadata reload without a restart",
+ "oracle": "api",
+ "verify": "post-reboot row keeps the admin wording; the reload path updates the untouched declared row",
+ "evidence": "before/after row reads for both paths"
+ },
+ {
+ "clause": "authoring rejections are loud and prescriptive: a stray key errors naming it with the alias prescription (content→bodyHtml per #5013 — landing on the REQUIRED body so the rename renders); a non-dotted-snake-case name errors at parse",
+ "oracle": "build",
+ "verify": "the two probe errors against EmailTemplateDefinitionSchema",
+ "evidence": "the error texts"
+ },
+ {
+ "clause": "the raw transactional send door (POST /api/v1/email/send → IEmailService.send, complementary to the sendTemplate path above) authenticates and validates: an AUTHED well-formed message lands at the dev transport (200 with result.status 'sent'); an ANONYMOUS send is refused 401 UNAUTHENTICATED (the #3963 unconditional gate — the api.requireAuth opt-out is retired); a MALFORMED body is refused 400 with a ledgered envelope code (INVALID_REQUEST for a non-object body, VALIDATION_FAILED for a bad message shape) — never a 500 for caller-fixable input, and a runtime with no email provider answers 501 NOT_IMPLEMENTED rather than a fake success",
+ "oracle": "api",
+ "verify": "the three POST /api/v1/email/send responses: authed 200 + dev-transport capture, anonymous 401 UNAUTHENTICATED, malformed 400 with the named code (rest-server.ts registerEmailEndpoints: enforceAuth, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider)",
+ "evidence": "the three responses + the dev-transport landing"
+ }
+ ],
+ "negative": [
+ "the false-compliance case #4509 named is the standing FAIL: an admin 'fixes' a template and recipients keep receiving the old copy — any render not matching the authoritative row is a FAIL against the bridge",
+ "a send with an unresolved {{placeholder}} residue delivered to the transport is a FAIL (render must substitute or refuse, never ship holes)"
+ ],
+ "traps": [
+ "stale-dist"
+ ],
+ "automated": {
+ "kind": "e2e",
+ "ref": "packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts"
+ },
+ "source": [
+ "packages/spec/src/system/email-template.zod.ts (requireds, dotted-name regex, aliases #5013, variables/required, fromOverride/replyTo/active)",
+ "packages/spec/liveness/email_template.json (the whole surface live via #4509; per-prop evidence incl. requireVars, TEMPLATE_INACTIVE, seed-not-clobber, runtime re-materialization)",
+ "packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts + email-service.ts (row mapping; (name,locale)+fallback; the gates)",
+ "examples/app-showcase/src/system/emails/index.ts (showcase_task_done_email fixture)",
+ "packages/rest/src/rest-server.ts (registerEmailEndpoints — POST /api/v1/email/send: enforceAuth 401 UNAUTHENTICATED #3963, non-object→400 INVALID_REQUEST, VALIDATION_FAILED passthrough, 501 no-provider, 500 EMAIL_SEND_FAILED)",
+ "packages/rest/src/rest-route-ledger.ts (email family — POST /api/v1/email/send → client email.send)",
+ "packages/spec/src/api/error-code-ledger.zod.ts (EMAIL_SEND_FAILED under @objectstack/rest)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — email_template kind had no checklist coverage",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 3,
+ "date": "2026-08-08",
+ "change": "added the raw POST /api/v1/email/send route clause (authed → dev transport, anonymous → 401 UNAUTHENTICATED, malformed → 400 envelope, no-provider → 501) per PENDING-GAPS §D",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "integration-system.notify-inbox-delivery",
+ "title": "The flow notify node delivers to the recipient's inbox (sys_inbox_message + receipt), readable and markable over /notifications, recipient-scoped — with unimplemented channels dead-lettering, never faking delivery",
+ "since": "v15",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "mixed",
+ "personas": [
+ "admin (to reassign the task)",
+ "the assignee member (to read the inbox)",
+ "a third member (for the scoping negative)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the shipped fixture flow showcase_task_assigned_notify (notify node: topic 'task.assigned', recipients ['{record.assignee}'], channels ['inbox'], title/message/actionUrl — examples/app-showcase/src/automation/flows/index.ts)",
+ "MessagingServicePlugin installed (the showcase config requires the 'messaging' capability)",
+ "three personas with sessions: admin, the assignee, an unrelated member"
+ ],
+ "knownGaps": [
+ "channels push/slack/teams/webhook have NO delivery implementation (#3197 — notification.zod.ts says the dispatcher dead-letters them, and the enum's 'in-app' spelling vs the implemented 'inbox' channel is a known naming drift); this item tests inbox only and records the dead-letter behavior as a negative, not as deliverable channels"
+ ]
+ },
+ "variants": [
+ "channel: inbox — implemented, always-on (this item's happy path)",
+ "channel: email — implemented via plugin-email (covered by email-template-render; not re-proven here)",
+ "channel: sms — implemented via service-sms (needs provider credentials; blocked on stock fixtures)",
+ "channels push/slack/teams/webhook — unimplemented, dead-letter (#3197): negative-only"
+ ],
+ "steps": [
+ "as admin, reassign a showcase_task to the assignee persona (assignee != previous.assignee fires the flow trigger)",
+ "as the ASSIGNEE, GET /api/v1/notifications (route ledger: notifications.list); locate the new row (title 'New task assigned: ', the actionUrl deep link)",
+ "as the THIRD member, GET /api/v1/notifications and confirm the notification is absent",
+ "unauthenticated GET /api/v1/notifications; capture the 401",
+ "as the assignee, POST /api/v1/notifications/read with { ids: [] }; re-list with ?read=false and confirm it dropped out; then POST /notifications/read/all",
+ "read the backing rows over the data API (sys_inbox_message + sys_notification_receipt keyed (notification_id, user_id, channel:'inbox')) to confirm the surface reflects storage",
+ "author a scratch flow copy whose notify node names channels ['push']; run it; capture where the message lands (dead-letter), and that NO inbox row was fabricated"
+ ],
+ "acceptance": [
+ {
+ "clause": "the notify node delivers: one inbox row for the assignee carrying the flow's title/message/actionUrl, listed over GET /notifications for that user",
+ "oracle": "api",
+ "verify": "the listing contains the notification with the authored strings after the reassignment",
+ "evidence": "the listing response"
+ },
+ {
+ "clause": "delivery is recipient-scoped — the third member's listing does NOT contain it (server-side scoping, not client filtering)",
+ "oracle": "api",
+ "verify": "the third member's authenticated listing omits the row",
+ "evidence": "both listings side by side"
+ },
+ {
+ "clause": "mark-read is persisted per (notification, user, channel): POST /notifications/read flips the receipt; an unread-filtered re-list omits it; read/all clears the rest — and the receipts are real rows, not client state",
+ "oracle": "api",
+ "verify": "the receipt row exists after markRead; the ?read=false listing shrinks accordingly",
+ "evidence": "the mutation responses + receipt row read"
+ },
+ {
+ "clause": "the surface gates correctly on BOTH sides: unauthenticated → 401; with the messaging service absent/unserveable the route answers the 501 capability-unavailable envelope (never a fabricated empty inbox from a stub)",
+ "oracle": "api",
+ "verify": "the anonymous 401; the 501 side may be cited from a minimal boot without the messaging plugin",
+ "evidence": "the two responses"
+ },
+ {
+ "clause": "an unimplemented channel dead-letters instead of faking success: the ['push'] probe produces NO inbox row and the dispatcher records the dead-letter — declared-but-unimplemented channels must stay visible failures (#3197)",
+ "oracle": "log",
+ "verify": "no sys_inbox_message row for the probe run; the dead-letter/log evidence captured",
+ "evidence": "absence check + log excerpt"
+ }
+ ],
+ "negative": [
+ "a notify step reporting success while the recipient's listing stays empty is a FAIL against the delivery chain (flow → messaging → inbox row → route), wherever it broke",
+ "the third member seeing another user's notification is a security FAIL (receipts and rows are per-user; the route must scope server-side)"
+ ],
+ "traps": [
+ "wrong-persona",
+ "seed-data-thin"
+ ],
+ "source": [
+ "packages/runtime/src/domains/notifications.ts (routes, auth:true, sys_inbox_message + sys_notification_receipt join, receipt key)",
+ "packages/runtime/src/route-ledger.ts (GET /notifications, POST /notifications/read, POST /notifications/read/all)",
+ "packages/spec/src/system/notification.zod.ts (channel enum + the #3197 implemented-vs-dead-letter note and 'in-app' vs 'inbox' drift)",
+ "packages/services/service-messaging/src/inbox-channel.ts + channel.ts (inbox delivery writes; channels default ['inbox'])",
+ "examples/app-showcase/src/automation/flows/index.ts (showcase_task_assigned_notify — the worked ADR-0012 notify fixture)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "new item: the notify→inbox→/notifications chain had no checklist coverage; unimplemented channels pinned as dead-letter negatives per #3197 instead of asserted as capabilities",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "integration-system.external-datasource-federated-read",
+ "title": "A declared external datasource federates: its objects query in place over REST, read-only",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "api",
+ "personas": [
+ "admin"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the shipped read-only SQLite external datasource (examples/app-showcase/src/system/datasources/ — showcase-external.datasource.ts + external-fixture.ts, External Customer / External Order)"
+ ]
+ },
+ "steps": [
+ "boot showcase isolated; sign in as admin",
+ "GET /api/v1/meta and confirm the federated objects are registered",
+ "GET /api/v1/data/ and read fixture rows",
+ "attempt a write (POST/PATCH) against the read-only external object; capture the refusal",
+ "open Setup → Datasources and confirm the connection is listed with its health"
+ ],
+ "acceptance": [
+ {
+ "clause": "the external datasource's objects appear in /meta and return the fixture rows over the normal /data query path — federation is transparent to the query layer",
+ "oracle": "api",
+ "verify": "/data/ returns the seeded external rows; filters/$top work as on a native object",
+ "evidence": "the reads"
+ },
+ {
+ "clause": "writes to a read-only external object are refused with a located error, not silently dropped or half-applied",
+ "oracle": "api",
+ "verify": "POST/PATCH → 4xx naming the read-only datasource",
+ "evidence": "the refusal"
+ },
+ {
+ "clause": "the connection is visible and health-badged in Setup → Datasources",
+ "oracle": "screenshot",
+ "verify": "the datasources admin page shows the external connection",
+ "evidence": "screenshot"
+ }
+ ],
+ "negative": [
+ "a write that appears to succeed against a read-only external source is a FAIL"
+ ],
+ "traps": [
+ "stale-dist"
+ ],
+ "source": [
+ "examples/app-showcase/src/system/datasources/ (showcase-external.datasource.ts, external-fixture.ts)",
+ "content/docs/capabilities/integrations.mdx (federated datasource claim)",
+ "packages/runtime route-ledger external-datasource family"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "initial — the coverage.json `datasource` waiver was STALE (showcase ships a read-only SQLite external fixture); un-waived. NOTE: the external-datasource ADMIN CRUD lifecycle is a separate item (datasource-admin-lifecycle)",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "integration-system.datasource-admin-lifecycle",
+ "title": "The /api/v1/datasources admin lifecycle: static driver catalog, runtime create with provenance+health, secret never echoes (hasSecret only), bad drafts 400 DATASOURCE_ADMIN_ERROR, unwired federation degrades 503 naming external-datasource",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "api",
+ "personas": [
+ "admin"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "os dev mounts the datasource admin routes at /api/v1/datasources by default (packages/cli/src/commands/serve.ts wires DatasourceAdminServicePlugin + registerDatasourceAdminRoutes when the engine is not 'memory')",
+ "the sqlite driver from the static catalog (packages/services/service-datasource/src/driver-catalog.ts) + a writable location for the sqlite file — the runtime-create probe target",
+ "a crypto provider / secret binder (createDatasourceSecretBinder) so the inline secret is bound, not stored cleartext"
+ ],
+ "knownGaps": [
+ "the external-datasource FEDERATION service is intentionally NOT wired in the admin-lifecycle boot — its absence is what the 503-naming clause verifies; a boot that DOES wire it should record that and skip the 503 clause as not-applicable-this-run"
+ ]
+ },
+ "steps": [
+ "boot showcase via os dev (serve.ts mounts /api/v1/datasources by default on a non-memory engine); sign in as admin",
+ "GET /api/v1/datasources/drivers; confirm the static catalog (memory/sqlite/postgres/mysql/mongo, each with a configSchema) — this route has NO service dependency and answers even before any datasource-admin service is wired",
+ "POST /api/v1/datasources with a sqlite-file draft { name: 'qa_ds_probe', driver: 'sqlite', config: { file: '' }, secret?: '...' }; capture status + the returned datasource",
+ "GET /api/v1/datasources; confirm qa_ds_probe appears with origin:'runtime' and a health field",
+ "GET /api/v1/datasources/qa_ds_probe; inspect the body for config + a hasSecret flag and confirm the cleartext secret value is ABSENT",
+ "POST /api/v1/datasources with a bad draft (invalid config shape / missing required); capture status + code",
+ "GET /api/v1/datasources/qa_ds_probe/remote-tables (an external-datasource-served route) on the boot with federation UNWIRED; capture the 503 + which service its message names",
+ "GET /api/v1/datasources/does-not-exist; capture the 404"
+ ],
+ "acceptance": [
+ {
+ "clause": "the driver catalog is static and always-available: GET /api/v1/datasources/drivers returns the curated driver set (memory/sqlite/postgres/mysql/mongo) each with a projected configSchema, with NO datasource-admin service dependency",
+ "oracle": "api",
+ "verify": "the drivers body against DRIVER_CATALOG (driver-catalog.ts); route answers even when the admin service is unwired",
+ "evidence": "the drivers response"
+ },
+ {
+ "clause": "a runtime create lands with provenance + health: POST /api/v1/datasources creates a sqlite-file datasource (201) and the subsequent list shows it with origin:'runtime' and a health status",
+ "oracle": "api",
+ "verify": "POST status 201; GET /api/v1/datasources contains qa_ds_probe with origin 'runtime' + a health field",
+ "evidence": "the create + list responses"
+ },
+ {
+ "clause": "the secret NEVER echoes back: the create body's inline secret is split out server-side (splitSecret) so it never reaches the persisted draft, and every read (getDatasource is credential-stripped) exposes only a hasSecret boolean plus non-sensitive config — the cleartext secret is returned by no GET",
+ "oracle": "api",
+ "verify": "GET /api/v1/datasources/qa_ds_probe carries hasSecret + config but no secret value; a grep of the list + detail bodies finds the secret nowhere",
+ "evidence": "the detail + list bodies (secret-absent)"
+ },
+ {
+ "clause": "a malformed draft is refused 400 DATASOURCE_ADMIN_ERROR — the datasource-admin service's registered refusal code (attributed to the service that refused, #4249), never a 500",
+ "oracle": "api",
+ "verify": "the bad-draft POST: status 400, error.code DATASOURCE_ADMIN_ERROR (registered under @objectstack/service-datasource)",
+ "evidence": "the 400 response"
+ },
+ {
+ "clause": "an unwired federation service degrades 503 naming external-datasource: the introspection routes (/:name/remote-tables, /:name/test, /:name/object-draft) answer 503 SERVICE_UNAVAILABLE whose message names the external-datasource service — NOT datasource-admin (the #4225 mis-attribution the resolve() helper exists to prevent, since datasource-admin itself is running fine)",
+ "oracle": "api",
+ "verify": "the remote-tables 503 message names 'external-datasource', not 'datasource-admin'",
+ "evidence": "the 503 response"
+ },
+ {
+ "clause": "unknown name → 404 RESOURCE_NOT_FOUND; and (FINDING) the /api/v1/datasources admin CRUD is UNLEDGERED — absent from packages/rest/src/rest-route-ledger.ts (only the /datasources/:name/external/* federation routes are ledgered there), a tranche-3 route-ledger discipline gap the run must record (PENDING-GAPS §E)",
+ "oracle": "api",
+ "verify": "GET /api/v1/datasources/does-not-exist → 404 RESOURCE_NOT_FOUND; run record notes the admin routes carry no route-ledger entry",
+ "evidence": "the 404 + the unledgered-mount finding"
+ }
+ ],
+ "negative": [
+ "the cleartext secret appearing in ANY list/detail response is a security FAIL — hasSecret is the only permitted signal",
+ "a federation-route 503 that names datasource-admin (the service that IS running) instead of external-datasource is the #4225 mis-attribution regressed — FAIL",
+ "a datasource-admin refusal carrying EXTERNAL_DATASOURCE_ERROR (or an external-datasource refusal carrying DATASOURCE_ADMIN_ERROR) is the #4249 code mis-attribution — FAIL"
+ ],
+ "traps": [
+ "dispatcher-vs-hono-route",
+ "stale-dist"
+ ],
+ "automated": {
+ "kind": "unit",
+ "ref": "packages/services/service-datasource/src/__tests__/admin-routes.test.ts (+ __tests__/envelope.conformance.test.ts) — pins route behavior + envelope; the LIVE-mount half is not pinned, drive os dev for it"
+ },
+ "source": [
+ "packages/services/service-datasource/src/admin-routes.ts (the nine routes; splitSecret keeps the secret out of the persisted draft; resolve()/badRequest() per-service attribution #4225/#4249; getDatasource credential-stripped + hasSecret)",
+ "packages/services/service-datasource/src/driver-catalog.ts (static DRIVER_CATALOG, configSchema projected from spec #4410)",
+ "packages/spec/src/api/error-code-ledger.zod.ts (DATASOURCE_ADMIN_ERROR, EXTERNAL_DATASOURCE_ERROR under @objectstack/service-datasource)",
+ "packages/spec/src/api/errors.zod.ts (HttpStatusErrorCodeMap: 503 SERVICE_UNAVAILABLE, 404 RESOURCE_NOT_FOUND)",
+ "packages/cli/src/commands/serve.ts (mounts registerDatasourceAdminRoutes at /api/v1/datasources by default — NOT in the REST route ledger, tranche-3 gap)",
+ "PENDING-GAPS §B/§E (#4225/#4249; service-datasource has no route ledger)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "new — the external-datasource ADMIN lifecycle (driver catalog, runtime create + provenance/health, secret-never-echoes, 400/503 per-service attribution, unledgered-mount finding); distinct from external-datasource-federated-read (which tests the seeded read-only fixture's query path) — cross-referenced, not duplicated. Per PENDING-GAPS §B/§E (#4225/#4249)",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "integration-system.notification-preference-suppression",
+ "title": "A recipient preference muting the inbox channel for a topic suppresses delivery (no inbox row, no dead-letter fake); flipping it back resumes; a sys_notification_template renders through a delivery",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "mixed",
+ "personas": [
+ "admin (to fire the notify flow by reassigning the task)",
+ "the assignee member (whose inbox is checked)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "MessagingServicePlugin installed (the showcase config requires the 'messaging' capability) — its emit() consults the PreferenceResolver before fan-out",
+ "the shipped notify flow showcase_task_assigned_notify (notify node: topic 'task.assigned', channels ['inbox'] — examples/app-showcase/src/automation/flows/index.ts)",
+ "two personas with sessions: admin and the assignee"
+ ],
+ "knownGaps": [
+ "no shipped sys_notification_preference rows nor a sys_notification_template fixture — both authored in-run (both are writable system-data objects); the notify flow fixture itself is shipped. The spec's channel enum spells this 'in-app'; the implemented channel id is 'inbox' (the #3197 naming drift) — the preference row must name the IMPLEMENTED id 'inbox'"
+ ]
+ },
+ "steps": [
+ "boot showcase isolated; personas admin + assignee; establish the happy path first (or lean on notify-inbox-delivery): reassign a showcase_task to the assignee → showcase_task_assigned_notify fires topic 'task.assigned' on channel 'inbox' → one sys_inbox_message for the assignee, visible in GET /api/v1/notifications",
+ "author a suppression row via POST /api/v1/data/sys_notification_preference: { user_id: , topic: 'task.assigned', channel: 'inbox', enabled: false } — this is the 'category' the task names; the model's field is `topic` (most-specific-wins over the built-in default-ON)",
+ "re-fire the notify flow (reassign the task to the same assignee again); GET /api/v1/notifications as the assignee and confirm NO new inbox row landed",
+ "read the messaging outbox / dead-letter surface and confirm there is no dead-letter row for the muted (recipient, 'inbox') — a deliberate mute is a DROP by the preference filter, not a dead-letter (dead-letter is reserved for unimplemented channels, #3197)",
+ "flip the row enabled:true (or delete it); re-fire; confirm the inbox row is delivered again",
+ "probe the resolution order: a user_id='*' admin-global default OFF with a user-specific row ON delivers (user row overrides global); a mandatoryTopics-configured topic bypasses the matrix entirely (cannot be muted)",
+ "render a sys_notification_template through a delivery: author a scratch sys_notification_template keyed (topic, channel, locale) and drive a template-backed channel (email/sms — messaging-service-plugin registers them as 'renders sys_notification_template'); capture the rendered subject/body"
+ ],
+ "acceptance": [
+ {
+ "clause": "a suppression row mutes the channel: with (assignee, 'task.assigned', 'inbox', enabled:false) present, firing the notify flow delivers NO inbox row to the assignee — the PreferenceResolver drops the recipient once no channel survives the filter (most-specific-wins; built-in default ON)",
+ "oracle": "api",
+ "verify": "post-fire GET /api/v1/notifications for the assignee lacks the new row; the preference row is present with enabled:false",
+ "evidence": "the empty-of-new listing + the preference row"
+ },
+ {
+ "clause": "no dead-letter fake: the muted delivery is DROPPED by preference, not recorded as a dead-letter/failed row — dead-letter is reserved for unimplemented channels (#3197); a deliberate mute must leave no fake failure trace",
+ "oracle": "api",
+ "verify": "no sys_inbox_message for the muted fire AND no dead-letter row for the (recipient, 'inbox') pair",
+ "evidence": "the absence checks (inbox + dead-letter)"
+ },
+ {
+ "clause": "flipping back resumes delivery: setting enabled:true (or removing the row) restores default-ON and the next fire delivers the inbox row again — the mute is reversible and reflects the CURRENT matrix, not a cached decision",
+ "oracle": "api",
+ "verify": "post-flip GET /api/v1/notifications shows the new inbox row",
+ "evidence": "the before/after listings around the flip"
+ },
+ {
+ "clause": "resolution is most-specific-wins: a user-specific row overrides the user_id='*' admin-global default, and a mandatoryTopics-configured topic bypasses the matrix (cannot be muted) — the ADR-0030 Layer-3 precedence holds on the live pipeline",
+ "oracle": "api",
+ "verify": "the global-OFF + user-ON probe delivers; a mandatory-topic fire delivers despite an enabled:false row",
+ "evidence": "the two probe outcomes"
+ },
+ {
+ "clause": "a sys_notification_template renders through a delivery: driving a template-backed channel resolves (topic, channel, locale) from sys_notification_template with locale fallback and renders the subject/body holes — proving the template surface is live and DISTINCT from the inline title/message the inbox flow uses",
+ "oracle": "log",
+ "verify": "the captured render carries the template's substituted subject/body (email/sms channel path; messaging-service-plugin 'renders sys_notification_template')",
+ "evidence": "the captured render"
+ }
+ ],
+ "negative": [
+ "a suppression row present and enabled:false but an inbox row STILL landing is a FAIL — the preference filter was bypassed (distinct from the by-design fail-OPEN, which applies only to a preference OUTAGE: no data engine / lookup error keeps all channels; a HEALTHY lookup ignoring an enabled:false row is the FAIL)",
+ "distinct from notify-inbox-delivery (the inbox happy path) — a run must not double-count the happy path here; this item's proof is the SUPPRESSION and its reversal"
+ ],
+ "traps": [
+ "wrong-persona",
+ "seed-data-thin",
+ "stale-dist"
+ ],
+ "source": [
+ "packages/services/service-messaging/src/objects/notification-preference.object.ts (sys_notification_preference: user_id × topic × channel × enabled; '*' wildcards + admin-global default; unique (user_id,topic,channel) index)",
+ "packages/services/service-messaging/src/preference-resolver.ts (PreferenceResolver.filter — most-specific-wins, mandatory-topic bypass, fail-open; drops recipients left with no accepted channel)",
+ "packages/services/service-messaging/src/messaging-service.ts (emit() consults the PreferenceResolver before fan-out)",
+ "packages/services/service-messaging/src/messaging-service-plugin.ts (email/sms channels render sys_notification_template; shared retry/dead-letter substrate)",
+ "packages/spec/src/system/notification.zod.ts (#3197 dead-letter for unimplemented channels; 'in-app' vs 'inbox' naming drift)",
+ "examples/app-showcase/src/automation/flows/index.ts (showcase_task_assigned_notify — topic 'task.assigned', channels ['inbox'])"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "new — preference-driven inbox suppression (no row, no dead-letter fake), reversal, ADR-0030 Layer-3 precedence, and a sys_notification_template render; distinct from notify-inbox-delivery (happy path). Per PENDING-GAPS §C",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json
new file mode 100644
index 0000000000..8a9ef2f1a2
--- /dev/null
+++ b/docs/qa/platform-checklist/areas/platform-core.json
@@ -0,0 +1,1020 @@
+{
+ "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.",
+ "area": "platform-core",
+ "title": "Platform core — boot, health, console shell, metadata pipeline",
+ "items": [
+ {
+ "id": "platform-core.boot-health",
+ "title": "Showcase boots clean: health + ready 200, no degraded startup banners, console + app metadata served",
+ "since": "v15",
+ "status": "active",
+ "revision": 2,
+ "priority": "P0",
+ "surface": "mixed",
+ "preconditions": [
+ "isolated run per dogfood-verification §0: own free port (not 3000/3001/3210, checked with lsof), own file DB (--seed-admin -d file:/tmp//data.db)"
+ ],
+ "steps": [
+ "boot examples/app-showcase via `objectstack dev --ui --seed-admin -p -d file:/tmp//data.db`",
+ "poll GET http://localhost:/api/v1/health until 200; record time-to-healthy",
+ "GET /api/v1/ready and record the status (the readiness probe is a separate route — packages/runtime/src/route-ledger.ts)",
+ "read the FULL boot log: capture the Flows: banner, any ⚠ lines, any ERROR-level lines, and every SeedLoader line",
+ "GET /_console/ and confirm the console shell HTML is served (200, text/html)",
+ "GET /api/v1/meta/app?id=com.example.showcase and capture the merged app/nav metadata",
+ "cross-check the nav payload: the grp_data group lists the seeded objects (showcase_project, showcase_task, showcase_account, showcase_contact, showcase_invoice, showcase_field_zoo, …)"
+ ],
+ "acceptance": [
+ {
+ "clause": "GET /api/v1/health returns 200 within the boot window",
+ "oracle": "api",
+ "verify": "curl -s -o /dev/null -w '%{http_code}' http://localhost:/api/v1/health → 200",
+ "evidence": "the curl output + time-to-healthy"
+ },
+ {
+ "clause": "GET /api/v1/ready returns 200 once boot completes (readiness, not just liveness)",
+ "oracle": "api",
+ "verify": "curl the /ready probe after health goes green",
+ "evidence": "the curl output"
+ },
+ {
+ "clause": "the `Flows:` startup banner reports no ⚠ misauthored flows and no ERROR-level lines appear anywhere in the boot log",
+ "oracle": "log",
+ "verify": "grep the boot log for '⚠' in the Flows banner and for ERROR lines; seed rejections count as failures (see #3415 — SeedLoader rejections were silent)",
+ "evidence": "the grepped log excerpt"
+ },
+ {
+ "clause": "the console shell is served at /_console/ and the seeded app's merged metadata resolves",
+ "oracle": "api",
+ "verify": "GET /_console/ → 200 text/html; GET /api/v1/meta/app?id=com.example.showcase returns the merged app/nav metadata (dogfood skill §1 names this exact endpoint)",
+ "evidence": "response statuses + top-level keys"
+ },
+ {
+ "clause": "the served nav matches the authored app: every grp_data object entry from src/ui/apps/index.ts appears in the meta/app response — nothing silently dropped at merge",
+ "oracle": "api",
+ "verify": "diff the nav object names in the response against examples/app-showcase/src/ui/apps/index.ts",
+ "evidence": "the diff (empty)"
+ }
+ ],
+ "negative": [
+ "any SeedLoader rejection line in the boot log is a FAIL against the seed (RUNNER rule 3: a defect in the fixture is a fail, not a block — #3408/#3415)",
+ "a health 200 with ERROR lines in the log is NOT a clean boot — the log clause fails independently of the probe"
+ ],
+ "traps": ["seed-data-thin", "stale-dist"],
+ "source": [
+ "dogfood-verification skill §0–§1",
+ "#3415",
+ "packages/runtime/src/route-ledger.ts (GET /health, GET /ready)",
+ "examples/app-showcase/src/ui/apps/index.ts (authored nav)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-07", "change": "initial — standing P0 smoke distilled from the dogfood boot protocol", "ref": "#3358" },
+ { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "platform-core.seed-integrity",
+ "title": "Seed integrity: row counts match the authored seed, values land verbatim, replay is idempotent",
+ "since": "v15",
+ "status": "active",
+ "revision": 1,
+ "priority": "P0",
+ "surface": "api",
+ "personas": ["seeded admin (admin@objectos.ai / admin123)"],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "a FRESH isolated boot (new file DB) so first-boot seed behavior is what is measured, then a restart against the SAME DB for the idempotence clause"
+ ]
+ },
+ "steps": [
+ "after a clean fresh boot, derive the expected per-object row counts from the app's own seed module (examples/app-showcase/src/data/seed/index.ts — count the records arrays; re-derive rather than trusting a stale list if seeds changed)",
+ "authoring-time baseline for cross-checking the derivation: showcase_account=14, showcase_contact=33 (9 named + 24 bulk prospects), showcase_project=5, showcase_task=10, showcase_invoice=12, showcase_invoice_line=5, showcase_expense_report=4, showcase_expense_line=13, showcase_field_zoo=2",
+ "for each seeded object, GET /api/v1/data/?$top=1 and read the server total; build the expected-vs-actual table",
+ "GET the Field Zoo 'Specimen — Full' row and spot-diff authored values (f_multiselect ['red','green'] as a set, f_lookup resolving to the Northwind account id, f_json nested object intact)",
+ "re-read the boot log's seed lines: every seed reports success; no rejection or partial-load line",
+ "restart the server against the SAME file DB; re-run the count sweep and diff against the first sweep (upsert mode must no-op, not duplicate)"
+ ],
+ "acceptance": [
+ {
+ "clause": "every seeded object's server row count equals the count authored in the seed module — no silent partial load",
+ "oracle": "api",
+ "verify": "expected-vs-actual table from GET /api/v1/data/?$top=1 totals vs the records arrays in src/data/seed/index.ts; zero mismatches",
+ "evidence": "the table"
+ },
+ {
+ "clause": "seed VALUES land verbatim, not just rows: the Specimen — Full spot-diff matches the authored literal (arrays as sets, JSON objects structurally, references resolved to real ids)",
+ "oracle": "api",
+ "verify": "field-by-field diff of the API read against the seed literal for the sampled fields",
+ "evidence": "the diff"
+ },
+ {
+ "clause": "the boot log reports every seed load as success — a rejection is a FAIL against the seed even when the server otherwise boots green (#3415: four of five projects were silently rejected once)",
+ "oracle": "log",
+ "verify": "grep the boot log for SeedLoader/seed lines; no rejected/failed entries",
+ "evidence": "the log excerpt"
+ },
+ {
+ "clause": "seed replay is idempotent: a restart against the same DB changes no count (upsert with externalId no-ops on unchanged rows)",
+ "oracle": "api",
+ "verify": "second count sweep diffs empty against the first",
+ "evidence": "both sweeps"
+ },
+ {
+ "clause": "deliberately-unseeded fields stay unseeded for their documented reasons (f_user/f_users: sys_user rows come from sign-up; f_secret: no CryptoProvider on the seed path; task.cover: a managed sys_file cannot be honestly seeded, #4891/ADR-0104) — their absence is CORRECT, not a gap to 'fix'",
+ "oracle": "api",
+ "verify": "the Specimen reads show these fields null/absent; record them as expected-absent in the run, never as findings",
+ "evidence": "the reads + the expected-absent list"
+ }
+ ],
+ "negative": [
+ "a count of 0 on any nav-visible object is a FAIL against the seed, never 'feature has nothing to show' (seed-data-thin trap: check row counts vs the built artifact, read the boot log)",
+ "counts that GROW on restart are a FAIL of upsert idempotence even though every row individually looks valid"
+ ],
+ "traps": ["seed-data-thin", "single-datapoint"],
+ "source": [
+ "examples/app-showcase/src/data/seed/index.ts (authored counts + expected-value comments)",
+ "#3408",
+ "#3415",
+ "packages/spec/src/data/seed-loader.zod.ts",
+ "RUNNER.md trap 'seed-data-thin'"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-07", "change": "initial — makes the #3408/#3415 class of silent seed failure a standing P0 check with exact authored baselines", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "platform-core.console-login",
+ "title": "Seeded admin signs in through the console; the session survives reload and re-authenticates cleanly after expiry",
+ "since": "v15",
+ "status": "active",
+ "revision": 2,
+ "priority": "P0",
+ "surface": "browser",
+ "personas": ["seeded admin (admin@objectos.ai / admin123)"],
+ "steps": [
+ "open /_console/ in the browser; screenshot the login form",
+ "sign in with the seeded admin credentials — drive the React controlled inputs with the native setter + input/change events, or POST the auth endpoint from the page (dogfood skill §4; naive fills submit empty)",
+ "screenshot the post-login shell; capture the first authed API responses",
+ "reload the page; capture the first authed API request after reload",
+ "expire the session: clear the auth cookies for the origin; then trigger an authed navigation/API call",
+ "observe the console's reaction (redirect to login vs dead shell); screenshot",
+ "sign in again and verify the console restores a working session on the same route",
+ "negative pass: sign out, then attempt login with a wrong password; capture the auth response and the UI"
+ ],
+ "acceptance": [
+ {
+ "clause": "login succeeds and lands in the console shell (nav + header rendered, not the login form)",
+ "oracle": "screenshot",
+ "verify": "post-login screenshot shows the app shell",
+ "evidence": "screenshot"
+ },
+ {
+ "clause": "the session survives a reload — the first authed API call after reload returns 200 with no redirect back to login",
+ "oracle": "network",
+ "verify": "network trace of the first authed request after reload (e.g. GET /api/v1/meta/app?id=com.example.showcase)",
+ "evidence": "the trace"
+ },
+ {
+ "clause": "an expired/cleared session is answered 401 by the SERVER on authed API calls — the deny side of the auth gate, proven on the wire",
+ "oracle": "network",
+ "verify": "after clearing cookies, the authed API call in the trace returns 401 (auth is the better-auth passthrough at /api/v1/auth/** — packages/runtime/src/route-ledger.ts; plugin-auth/src/auth-route-ledger.ts)",
+ "evidence": "the 401 trace"
+ },
+ {
+ "clause": "the console reacts to expiry by returning the user to login (or an explicit re-auth prompt) — never a dead shell rendering stale data as if authed",
+ "oracle": "screenshot",
+ "verify": "post-expiry screenshot shows the login/re-auth surface",
+ "evidence": "screenshot"
+ },
+ {
+ "clause": "re-authentication after expiry restores a working session: the same authed calls return 200 again and the shell renders current data",
+ "oracle": "network",
+ "verify": "post-re-login trace shows 200s; a mutation or fresh read succeeds",
+ "evidence": "the trace + screenshot"
+ }
+ ],
+ "negative": [
+ "a wrong password is rejected with a visible, named error AND no session cookie is set — a silent no-op or an error-free bounce is a FAIL",
+ "an expired session that keeps serving the shell with cached data (no 401, no redirect) is a FAIL of the expiry clause"
+ ],
+ "traps": ["automation-input", "shared-browser-tab"],
+ "source": [
+ "dogfood-verification skill §4 (React controlled inputs; auth POST fallback)",
+ "packages/runtime/src/route-ledger.ts ('* /auth/**' better-auth passthrough)",
+ "--seed-admin credentials: dogfood-verification skill §0"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-07", "change": "initial", "ref": "#3358" },
+ { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "platform-core.nav-surfaces-render",
+ "title": "Every showcase nav surface renders without page errors, and failures surface the error boundary — never a blank page",
+ "since": "v15",
+ "status": "active",
+ "revision": 2,
+ "priority": "P0",
+ "surface": "browser",
+ "personas": ["seeded admin (admin@objectos.ai / admin123)"],
+ "fixtures": {
+ "app": "showcase",
+ "knownGaps": [
+ "no stock showcase fixture deliberately throws inside a route, so the ErrorBoundary fallback ('Something went wrong' + Try Again / Go Home — objectui packages/app-shell/src/chrome/ErrorBoundary.tsx) is verified opportunistically on any failure encountered, plus via the bad-route probe below"
+ ]
+ },
+ "steps": [
+ "run the pinned suite: pnpm -C examples/app-showcase test:smoke (the SURFACES array in e2e/showcase-smoke.spec.ts — 31 surfaces: pages, object lists, dashboards, reports, the view-gallery pages)",
+ "GET /api/v1/meta/app?id=com.example.showcase and extract every nav destination from the response",
+ "diff the served nav destinations against the SURFACES array; hand-walk any destination the suite does not cover (screenshot first, then DOM)",
+ "on each hand-walked surface: wait for render, screenshot, then check for pageerror / empty / placeholder leaks ('no actions configured')",
+ "probe the failure path: navigate to a nonexistent route under the app (/_console/apps/com.example.showcase/object_that_does_not_exist); screenshot what renders",
+ "if ANY surface fails during the sweep, verify the failure presents as the shell's error boundary or a named empty/error state — capture it"
+ ],
+ "acceptance": [
+ {
+ "clause": "no surface throws a pageerror, renders an empty , or leaks a 'no actions configured' placeholder; chart surfaces draw a real SVG",
+ "oracle": "test",
+ "verify": "pnpm -C examples/app-showcase test:smoke (SURFACES array in e2e/showcase-smoke.spec.ts) — green",
+ "evidence": "test run output"
+ },
+ {
+ "clause": "the smoke's coverage is CURRENT: every nav destination served in meta/app is either in the SURFACES array or hand-walked this run — no surface silently outside the net",
+ "oracle": "api",
+ "verify": "diff of served nav destinations vs SURFACES + the hand-walk records for the remainder",
+ "evidence": "the diff + per-surface screenshots"
+ },
+ {
+ "clause": "a bad route renders a NAMED not-found/error state inside the shell — nav and header stay alive, never a white page or dead shell",
+ "oracle": "dom",
+ "verify": "after the screenshot confirms the shell rendered, assert the main region carries an explicit empty/error message for the nonexistent object",
+ "evidence": "screenshot + DOM excerpt"
+ },
+ {
+ "clause": "any render failure encountered anywhere in the sweep surfaces the route-level ErrorBoundary fallback (recoverable via Try Again), not a blank page",
+ "oracle": "screenshot",
+ "verify": "if a failure occurs: screenshot shows the boundary fallback; Try Again re-mounts the route; record none-encountered explicitly otherwise",
+ "evidence": "failure screenshot or the explicit none-encountered note"
+ }
+ ],
+ "negative": [
+ "a surface that renders a blank is a FAIL even with no console error — transitional emptiness must be ruled out by the screenshot-first protocol, then the persistent blank is the finding",
+ "a bad route producing a white page (shell gone) is a FAIL of the error-boundary clause"
+ ],
+ "automated": { "kind": "e2e", "ref": "examples/app-showcase/e2e/showcase-smoke.spec.ts" },
+ "traps": ["hydration-race", "single-datapoint", "stale-console-bundle"],
+ "source": [
+ "examples/app-showcase/e2e/showcase-smoke.spec.ts (SURFACES)",
+ ".github/workflows/showcase-smoke.yml",
+ "objectui: packages/app-shell/src/chrome/ErrorBoundary.tsx (route-level fallback + recovery actions)",
+ "examples/app-showcase/src/ui/apps/index.ts (nav truth)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-07", "change": "initial — wraps the existing automated smoke as a checklist row so runs report it alongside manual items", "ref": "#3358" },
+ { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "platform-core.metadata-registry-serving",
+ "title": "The metadata registry is served over REST: /meta lists every registered type with its spec-derived create seed",
+ "since": "v15",
+ "status": "active",
+ "revision": 2,
+ "priority": "P1",
+ "surface": "api",
+ "personas": ["seeded admin (admin@objectos.ai / admin123)"],
+ "fixtures": { "app": "showcase" },
+ "steps": [
+ "authenticated GET /api/v1/meta — the types listing (entries[]); capture the response",
+ "check the entries against DEFAULT_METADATA_TYPE_REGISTRY (packages/spec/src/kernel/metadata-plugin.zod.ts): the built-in kinds the showcase registers are present (object, view, page, dashboard, app, action, report, dataset, flow, seed, mapping, permission, position, translation, email_template, doc, book, datasource, api, …)",
+ "GET /api/v1/meta/types (the richer Studio listing — a distinct server-only route per packages/runtime/src/route-ledger.ts) and capture it",
+ "GET /api/v1/meta/view/showcase_task and confirm the stored view item is served with its authored shape (list + listViews + formViews keys)",
+ "GET /api/v1/meta/:type for 'view' and confirm the showcase's authored views are enumerated",
+ "run the pinned create-seed contract: pnpm --filter @objectstack/dogfood exec vitest run test/meta-types-create-seed.dogfood.test.ts",
+ "object-extension overlay: GET /api/v1/meta/object/showcase_account and confirm the additive fields contributed by examples/app-showcase/src/data/extensions/account.extension.ts (loyalty_tier select bronze/silver/gold/platinum, linkedin_url url, csat_score number 0–100) are present in the MERGED object — the extension carries priority 210 and never re-declares showcase_account (defineObjectExtension, merged at registerApp)",
+ "render + round-trip the overlay: open a showcase_account record form and confirm the three extension fields render alongside the base fields; PATCH /api/v1/data/showcase_account/ setting loyalty_tier='gold' and csat_score=88, then GET the row and confirm both persisted (the overlay is a real column, not a display-only badge)"
+ ],
+ "acceptance": [
+ {
+ "clause": "GET /api/v1/meta answers 200 with a non-empty entries[] naming the registered metadata types",
+ "oracle": "api",
+ "verify": "response parses; entries.length > 0; the registry kinds listed in the steps are all present",
+ "evidence": "the response + the presence table"
+ },
+ {
+ "clause": "entries carry the AUTHORITATIVE spec-derived create seeds: dashboard's seed is {widgets: []}, action's is a script with a valid js body, and report exposes NO seed by design (canvas-create) — consumers derive create defaults from the spec, not re-invent them",
+ "oracle": "test",
+ "verify": "pnpm --filter @objectstack/dogfood exec vitest run test/meta-types-create-seed.dogfood.test.ts — green (it asserts entry.createSeed equals getMetadataCreateSeed(type) for every seeded registered type)",
+ "evidence": "test run output"
+ },
+ {
+ "clause": "GET /api/v1/meta/types (the richer Studio-facing listing) is served — a real route distinct from GET /meta, per the route ledger",
+ "oracle": "api",
+ "verify": "authenticated GET returns 200 with the type registry payload",
+ "evidence": "the response"
+ },
+ {
+ "clause": "a stored item is retrievable by type+name with its authored shape: GET /api/v1/meta/view/showcase_task returns the task view gallery as authored",
+ "oracle": "api",
+ "verify": "the response carries the authored keys (list, listViews incl. in_progress/board/gantt, formViews incl. wizard/quick)",
+ "evidence": "the response's key inventory"
+ },
+ {
+ "clause": "type-scoped listing works: GET /api/v1/meta/view enumerates the showcase's authored views (task, project, contact, field-zoo, business-unit, inquiry families present)",
+ "oracle": "api",
+ "verify": "the listing contains the expected view names from examples/app-showcase/src/ui/views/",
+ "evidence": "the listing"
+ },
+ {
+ "clause": "an object extension merges ADDITIVELY into the served object: GET /api/v1/meta/object/showcase_account carries the account.extension.ts fields (loyalty_tier, linkedin_url, csat_score) merged in without re-declaring the object — the mechanism a package uses to extend an object it does not own",
+ "oracle": "api",
+ "verify": "the /meta/object response's fields include all three overlay fields with their declared types/options; showcase_account is authored once (defineObjectExtension priority 210 wins on conflict, merged at registerApp)",
+ "evidence": "the merged field inventory"
+ },
+ {
+ "clause": "the overlay fields are real columns, not display-only: they render on the showcase_account form and a write to loyalty_tier + csat_score round-trips through /api/v1/data/showcase_account",
+ "oracle": "api",
+ "verify": "PATCH /api/v1/data/showcase_account/ {loyalty_tier:'gold', csat_score:88} then GET the row returns both values; a screenshot confirms the fields render on the form",
+ "evidence": "the PATCH + GET pair + the form screenshot"
+ }
+ ],
+ "negative": [
+ "a registered type missing from GET /meta (or served without its registry contract) is a FAIL — the Studio designer derives its create UX from this response, and drift here produced real create-save 422s (the dashboard-layout / action-body incidents the pinned test memorializes)",
+ "an extension field present in account.extension.ts but ABSENT from GET /meta/object/showcase_account (or present in /meta but not writable through /data) is a FAIL — the overlay did not merge, and a package's additive fields would silently vanish",
+ "an unauthenticated GET /api/v1/meta that returns the full registry is a finding for the access-security area — capture and cross-file it, do not tick past it"
+ ],
+ "traps": ["dispatcher-vs-hono-route"],
+ "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/meta-types-create-seed.dogfood.test.ts" },
+ "source": [
+ "packages/spec/src/kernel/metadata-plugin.zod.ts (MetadataTypeSchema + DEFAULT_METADATA_TYPE_REGISTRY)",
+ "packages/spec/src/kernel/metadata-create-seeds.ts",
+ "packages/runtime/src/route-ledger.ts (GET /meta, GET /meta/types, GET /meta/:type, GET /meta/:type/:name)",
+ "examples/app-showcase/src/data/extensions/account.extension.ts (AccountExtension — additive overlay on showcase_account, priority 210)",
+ "packages/spec/src/data (defineObjectExtension — extend merges fields at registerApp, higher priority wins on conflict)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-07", "change": "initial — pins the registry-serving contract the Studio create path depends on", "ref": "claude/platform-test-checklist-ocwugl" },
+ { "revision": 2, "date": "2026-08-08", "change": "added the object-extension-overlay clauses: account.extension.ts (Loyalty Tier / LinkedIn / CSAT) merges additively into /meta/object/showcase_account, renders on the form, and round-trips a write", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "platform-core.metadata-authoring-roundtrip",
+ "title": "Metadata authoring round-trip: draft → publish on a WRITABLE package; read-only packages and locked types are server-side refused",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "mixed",
+ "personas": ["seeded admin (admin@objectos.ai / admin123)"],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "a runtime-created WRITABLE package to author into (create one via Studio or POST /api/v1/packages) — the showcase's own code package is read-only by design (ADR-0070)"
+ ]
+ },
+ "steps": [
+ "create a writable package (Studio package switcher → new, or POST /api/v1/packages); record its id",
+ "save a draft view into it: PUT /api/v1/meta/view/os_qa_view_ with a minimal valid ListView body bound to showcase_task and the writable packageId; capture the response",
+ "GET /api/v1/meta/_drafts and confirm the draft is listed",
+ "publish: POST /api/v1/packages//publish-drafts; then GET /api/v1/meta/view/os_qa_view_/published and capture it",
+ "verify in the console that the published view is live for showcase_task (reload, open the view switcher)",
+ "denied side A: attempt the same runtime create targeting the read-only CODE package; capture the refusal (expect writable_package_required — ADR-0070 D1)",
+ "denied side B: attempt a PUT overlay against a PACKAGED object item (type 'object' has allowOrgOverride: false); capture the refusal (expect HTTP 403 not_overridable)",
+ "Studio browser half: create a new record page via Studio bound to an object and capture the PUT /api/v1/meta/page/... it issues"
+ ],
+ "acceptance": [
+ {
+ "clause": "a draft saved via PUT /api/v1/meta/view/:name persists and is listed by GET /api/v1/meta/_drafts",
+ "oracle": "api",
+ "verify": "the PUT succeeds; the drafts listing contains os_qa_view_ (routes per packages/runtime/src/route-ledger.ts: PUT /meta/:type/:name, GET /meta/_drafts)",
+ "evidence": "PUT response + drafts listing"
+ },
+ {
+ "clause": "publish-drafts promotes the draft: GET /meta/view/:name/published serves the authored body afterwards, and the console renders the view after reload",
+ "oracle": "api",
+ "verify": "POST /packages/:id/publish-drafts → success; the /published read returns the body; console screenshot shows the view in the switcher",
+ "evidence": "publish response + published read + screenshot"
+ },
+ {
+ "clause": "Studio's designer authors through the same pipeline: creating a record page issues PUT /api/v1/meta/page/ bound to its object and seeded from the default layout",
+ "oracle": "network",
+ "verify": "capture the PUT during Studio create (pinned by objectui e2e/live/studio-record-page.spec.ts, which waits on exactly that request)",
+ "evidence": "the captured PUT"
+ },
+ {
+ "clause": "DENY side of the package gate: a runtime-only create targeting a read-only code/installed package is REJECTED with writable_package_required — not silently coerced to a package-less orphan (the pre-ADR-0070 #2252 bug)",
+ "oracle": "api",
+ "verify": "the refusal names writable_package_required (pinned by packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts)",
+ "evidence": "the refusal response"
+ },
+ {
+ "clause": "DENY side of the overlay gate: a per-org overlay write against a type with allowOrgOverride unset (object/field) answers HTTP 403 not_overridable, while view/dashboard (the ADR-0005 Phase 1 opt-ins) accept",
+ "oracle": "api",
+ "verify": "the object-targeting PUT returns 403 not_overridable (contract stated on allowOrgOverride in packages/spec/src/kernel/metadata-plugin.zod.ts); the view PUT from the happy path succeeded",
+ "evidence": "both responses side by side"
+ },
+ {
+ "clause": "a malformed body is refused by schema validation, never stored: a PUT with an invalid shape for the type answers a named validation error (validateOnWrite; the #5206 lesson — an unvalidated store is the defect)",
+ "oracle": "api",
+ "verify": "PUT /api/v1/meta/view/os_qa_bad_ with a nonsense body (e.g. columns: 42) → 4xx naming the validation failure; a subsequent GET finds no stored item",
+ "evidence": "the refusal + the empty GET"
+ }
+ ],
+ "negative": [
+ "a 200 on either deny-side attempt (read-only package, locked overlay type) is a FAIL — both gates are server-side contracts, not Studio courtesies",
+ "a malformed metadata body stored as-is (200 on PUT, garbage on GET) is a FAIL — this is the exact pre-#5271 'api' kind hole",
+ "a published view that never appears in the console after reload is a FAIL of the round-trip even though every API call returned success (check against a fresh objectui build before filing — stale-console-bundle)"
+ ],
+ "traps": ["dispatcher-vs-hono-route", "stale-console-bundle"],
+ "automated": { "kind": "api", "ref": "packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts; objectui: e2e/live/studio-record-page.spec.ts, e2e/live/studio-object-designer.spec.ts" },
+ "source": [
+ "packages/runtime/src/route-ledger.ts (PUT /meta/:type/:name, GET /meta/_drafts, GET /meta/:type/:name/published, POST /packages/:id/publish-drafts)",
+ "packages/spec/src/kernel/metadata-plugin.zod.ts (allowOrgOverride 403 not_overridable contract; validateOnWrite; registry flags per type)",
+ "ADR-0070 via packages/qa/dogfood/test/package-first-authoring.dogfood.test.ts (writable_package_required)",
+ "ADR-0033 (drafts / publish)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-07", "change": "initial — grounds the Studio authoring pipeline end-to-end with both deny gates as first-class clauses", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "platform-core.builtin-apps-nav-render",
+ "title": "The three built-in apps (Setup / Studio / Account) render every merged-nav destination; app-level and entry-level gates are enforced, not errored",
+ "since": "v15",
+ "status": "active",
+ "revision": 1,
+ "priority": "P0",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)",
+ "a plain member (fresh runtime sign-up — lands in member_default, holds neither setup.access nor studio.access)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the three platform apps ship with @objectstack/platform-objects and register as one-app packages @objectstack/{setup,studio,account} (ADR-0048), so their ids are com.objectstack.setup / com.objectstack.studio / com.objectstack.account — present on any stock boot, no showcase-specific fixture",
+ "a second, non-admin persona: sign up a fresh user in-run to drive the app-level and entry-level permission gates (do not reuse the admin — wrong-persona masks the guard)"
+ ],
+ "knownGaps": [
+ "the SSO Providers entry (nav_sso_providers → sys_sso_provider) is contributed ONLY when the external-IdP RP is wired (OS_SSO_ENABLED self-host / cloud planAllowsSso — plugin-auth auth-plugin.ts isSsoWired()). On a stock open-framework boot it is ABSENT by design (not gated-and-erroring); assert its absence, never treat it as a missing surface"
+ ]
+ },
+ "steps": [
+ "as admin, GET /api/v1/meta/app?id=com.objectstack.setup and extract every merged nav destination — Setup is a SHELL of empty group anchors (packages/platform-objects/src/apps/setup.app.ts: group_overview/apps/people_org/access_control/approvals/configuration/diagnostics/integrations/advanced) filled by SETUP_NAV_CONTRIBUTIONS (setup-nav.contributions.ts) plus capability-plugin contributions (plugin-security Positions/Permission Sets, plugin-sharing Sharing Rules/Record Shares, plugin-approvals, plugin-webhooks)",
+ "GET /api/v1/meta/app?id=com.objectstack.studio (studio.app.ts — static nav: Overview, Data Model, User Experience, Logic, Automation, AI, Developer, Integration) and GET /api/v1/meta/app?id=com.objectstack.account (account.app.ts — Profile + Inbox/Security/Developer groups)",
+ "as admin, hand-walk each destination in each app: navigate, wait for render, SCREENSHOT FIRST, then read the DOM — assert no pageerror, no 'failed to load', no blank , no 'no actions configured' placeholder",
+ "Setup detail: confirm each settings URL entry (nav_settings_* → /apps/setup/system/settings/) opens the settings namespace form (localization/company/branding/auth/mail/storage/ai/knowledge/feature_flags), and Users / Organization / Business Units / Teams / Sessions / OAuth Applications / Identity Links / User Preferences render",
+ "Studio detail: confirm each metadata:resource list (object/app/view/page/dashboard/report/dataset/action/hook/flow/agent/tool/skill/email_template) renders, and the component surfaces render (App Builder studio:builder, All Metadata Types metadata:directory, Packages developer:packages, API Console developer:api-console, Flow Runs developer:flow-runs, Public Forms developer:public-forms)",
+ "Account detail: confirm Profile (account:profile_card), Notifications (sys_inbox_message/mine), Approvals (sys_approval_request/my_pending), Linked Accounts (sys_account), Active Sessions (sys_session/mine), API Keys (sys_api_key/mine), OAuth Applications (sys_oauth_application/mine) each render",
+ "confirm the gated entries resolve to ABSENT-not-erroring for the admin: nav_organizations (requiresService org-scoping) is absent in single-org mode; nav_jwks Signing Keys (requiredPermissions manage_platform_settings, sys_jwks private per ADR-0066) is PRESENT for admin; SSO Providers is absent unless OS_SSO_ENABLED (knownGap)",
+ "sign in as the plain member: attempt to open Setup (com.objectstack.setup) and Studio (com.objectstack.studio) — capture the app-level refusal (App.requiredPermissions setup.access / studio.access); open Account (declares no requiredPermissions) — capture it opening",
+ "as the member, GET /api/v1/meta/app for a reachable app and confirm every manage_platform_settings-gated entry (JWKS, API Keys, the manage_platform_settings settings URLs) is ABSENT from the member's payload — the server prunes, the client does not merely hide",
+ "cross-check served-vs-rendered: diff each app's meta/app nav destinations against what actually rendered so nothing is silently outside the walk"
+ ],
+ "acceptance": [
+ {
+ "clause": "every merged-nav destination in all three apps renders a real surface for the admin — no pageerror, no 'failed to load', no blank , no placeholder leak; this closes the gap platform-core.nav-surfaces-render leaves (it only sweeps the showcase app)",
+ "oracle": "screenshot",
+ "verify": "per-destination screenshot (screenshot-first) then DOM mark-check, across Setup + Studio + Account",
+ "evidence": "per-app per-destination screenshot set + verdict table"
+ },
+ {
+ "clause": "the served merged nav matches each app's authored shell + contributions: Setup's group anchors (setup.app.ts) are filled by SETUP_NAV_CONTRIBUTIONS and capability plugins; Studio's static groups (studio.app.ts) are all present; Account's Profile/Inbox/Security/Developer groups present",
+ "oracle": "api",
+ "verify": "the three GET /meta/app payloads list the expected group ids + entries per the app source files",
+ "evidence": "the three nav payloads"
+ },
+ {
+ "clause": "as a plain member, Setup and Studio REFUSE (app-level requiredPermissions setup.access / studio.access) with a named access-denied surface — never a blank shell — while Account OPENS (declares no requiredPermissions, RLS scopes its rows)",
+ "oracle": "screenshot",
+ "verify": "run the two refusals + the Account open as the member persona (wrong-persona trap — do it as the member, not the admin)",
+ "evidence": "three screenshots"
+ },
+ {
+ "clause": "the app-level gate is server-side, not a client courtesy: a forged member GET /api/v1/meta/app?id=com.objectstack.setup is denied/empty at the server, not merely hidden in the launcher (ADR-0057 D10 both-sides)",
+ "oracle": "api",
+ "verify": "the forged request's status/body proves server-side denial",
+ "evidence": "the forged response"
+ },
+ {
+ "clause": "permission-gated ENTRIES are absent from the member's merged nav, not present-and-erroring: JWKS (nav_jwks), API Keys (nav_api_keys) and the manage_platform_settings settings entries do not appear in the member's payload",
+ "oracle": "api",
+ "verify": "diff of admin vs member /meta/app nav destinations — the gated entries are only in the admin set",
+ "evidence": "the admin-vs-member diff"
+ },
+ {
+ "clause": "service/object-gated entries resolve to ABSENCE on stock fixtures rather than an entry that can only error: nav_organizations (requiresService org-scoping) absent in single-org mode; nav_business_units (requiresObject sys_business_unit) present only when the object is registered",
+ "oracle": "api",
+ "verify": "the gated-entry presence in the served payload matches the requiresService/requiresObject conditions in setup-nav.contributions.ts / account.app.ts",
+ "evidence": "the gated-entry presence table"
+ },
+ {
+ "clause": "an off-capability destination is PRUNED from the nav, never rendered as a dead 'failed to load' list — the setup-nav.contributions.ts rationale (sys_verification/sys_device_code omit list; SSO absent unless wired) holds at runtime",
+ "oracle": "screenshot",
+ "verify": "the absent entries do not appear; SSO Providers absent on stock boot per the knownGap",
+ "evidence": "absence confirmation + the knownGap note"
+ }
+ ],
+ "negative": [
+ "a built-in app destination that renders a blank or a 'failed to load' / 'no actions configured' placeholder with no error boundary is a FAIL (screenshot-first rules out transitional emptiness, then the persistent blank is the finding)",
+ "the member reaching Setup or Studio content (app gate bypassed) is a FAIL — App.requiredPermissions is a server contract, prove denial on the wire, not just a hidden launcher tile",
+ "a permission-gated entry rendered for the member and then erroring on click is a FAIL of the 'absent not erroring' contract — the nav must prune server-side"
+ ],
+ "traps": ["hydration-race", "wrong-persona", "stale-console-bundle"],
+ "source": [
+ "packages/platform-objects/src/apps/setup.app.ts (shell group anchors + requiredPermissions setup.access)",
+ "packages/platform-objects/src/apps/studio.app.ts (static nav; requiredPermissions studio.access)",
+ "packages/platform-objects/src/apps/account.app.ts (no requiredPermissions; hidden from App Switcher; requiresObject/requiresService entry gates)",
+ "packages/platform-objects/src/apps/setup-nav.contributions.ts (nav_jwks manage_platform_settings; nav_organizations requiresService org-scoping; nav_api_keys manage_platform_settings)",
+ "packages/plugins/plugin-auth/src/auth-plugin.ts (SSO Providers nav_sso_providers contributed only when isSsoWired())",
+ "ADR-0048 (Setup/Studio/Account as one-app packages com.objectstack.{setup,studio,account}); ADR-0029 (nav contributions); ADR-0066 (sys_jwks private)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-08", "change": "initial — the big built-in-apps nav sweep (Setup/Studio/Account merged nav render + app-level and entry-level gates both-sides), complementing nav-surfaces-render which only walks the showcase app", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "platform-core.settings-hub-roundtrip",
+ "title": "Settings hub round-trip: a value saves, PERSISTS, reaches an observable consumer, audits, env-locks, tests honestly, and stores secrets as handles — anonymous denied",
+ "since": "v15",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "mixed",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123 — holds setup.access/setup.write/manage_platform_settings)",
+ "anonymous"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the 10 open-framework settings namespaces registered by @objectstack/service-settings (packages/services/service-settings/src/manifests: localization, company, branding, auth, mail, sms, storage, ai, knowledge, feature_flags)",
+ "the settings service wired with a data engine so it swaps from in-memory to the sys_setting table, and with the sys_secret store + auditWriter (SettingsServicePlugin binds these on kernel:ready; the showcase stack mounts the engine)"
+ ],
+ "knownGaps": [
+ "HONORING the ai.json caveat: a saved settings FORM is NOT proof of effect — the AI namespace's test action is a stub validator until @objectstack/service-ai is mounted, and a saved ai.provider does nothing observable without it. The effect clause therefore uses localization, whose consumer (resolve-authz-context.ts reads localization.timezone/locale/currency onto every ExecutionContext) is stock and observable; branding.workspace_name in the shell is the browser fallback. Never tick effect off a persisted form."
+ ]
+ },
+ "steps": [
+ "as admin, GET /api/settings — capture the visible manifests (the 10 open-framework namespaces above; the route is the SettingsService REST surface in settings-routes.ts)",
+ "pick localization (an OBSERVABLE-CONSUMER namespace — packages/core/src/security/resolve-authz-context.ts reads localization.timezone/locale/currency onto ExecutionContext). GET /api/settings/localization — capture {manifest, values}; note timezone value 'UTC' with source 'default'",
+ "change it: PUT /api/settings/localization { timezone: 'Asia/Tokyo' }; re-read GET /api/settings/localization and confirm the value persists and source FLIPS default→tenant (localization scope is 'tenant')",
+ "prove EFFECT (not just the saved form): re-derive an ExecutionContext-dependent output — an analytics date-bucket aggregate or a today()/rendered-datetime on showcase_task — and confirm it now resolves against Asia/Tokyo, not UTC. If the deployment surfaces no observable localization effect, fall back to branding.workspace_name visible in the console shell — but never tick effect off the form alone",
+ "audit: GET /api/v1/data/sys_setting_audit filtered to namespace=localization,key=timezone — a row exists with action='set', source='api', actor_id=, new_hash set (SettingsServicePlugin.buildAuditWriter → sys_setting_audit)",
+ "source badge: open /apps/setup/system/settings/localization and confirm the timezone field's source indicator flips from default to the persisted scope after save; screenshot",
+ "env-lock: set OS_LOCALIZATION_TIMEZONE=Europe/Paris (envKeyOf convention: OS__, settings-service.types.ts) and restart; GET /api/settings/localization now reports timezone source='env', locked=true, lockedReason 'Set via env: OS_LOCALIZATION_TIMEZONE', and the console renders the EnvLockBadge; PUT /api/settings/localization { timezone: 'UTC' } is REFUSED 409 SETTINGS_LOCKED (server-side, effectiveEnvOverride)",
+ "test action at the dev transport: POST /api/settings/mail/test with provider=log (or no email plugin mounted) — the handler answers ok:false and NO mail is faked (mail.manifest.ts mailTestActionHandler / plugin-email honest degradation, framework#5087)",
+ "secret handling: on a namespace with an encrypted specifier (mail.smtp_password or ai.*_api_key — type 'password' or encrypted:true), PUT a value; then read sys_secret + sys_setting — the ciphertext lands in sys_secret keyed by a 'sec_' handle and sys_setting.value_enc holds the handle id, NOT plaintext; GET /api/settings never returns the plaintext",
+ "anonymous deny: GET /api/settings/localization with no session → 403 SETTINGS_FORBIDDEN (assertPermitted read); GET /api/settings (list) as anon returns an EMPTY manifest set — zero namespaces enumerated (Finding-1 secure default)",
+ "guardrails: PUT /api/settings/localization { timezone: 'Mars/Phobos' } → 400 SETTINGS_VALIDATION invalid_option (declared options table); PUT { bogus_key: 1 } → 400 UNKNOWN_KEY"
+ ],
+ "acceptance": [
+ {
+ "clause": "a settings value round-trips: PUT /api/settings/localization persists and GET re-reads it, with source FLIPPING default→tenant (the scope), not staying 'default'",
+ "oracle": "api",
+ "verify": "the PUT + GET responses; the timezone value is Asia/Tokyo and source is the scope, not 'default'",
+ "evidence": "the PUT + GET bodies with the source field"
+ },
+ {
+ "clause": "EFFECT, not just a form: the saved value reaches its consumer — localization.timezone resolves onto ExecutionContext (resolve-authz-context.ts), so an ExecutionContext-dependent output (analytics date bucket / today() / rendered datetime) shifts from UTC to the saved zone",
+ "oracle": "api",
+ "verify": "the before/after output pair differs by exactly the zone change; NOTE (ai.json caveat) this clause requires an observable consumer — only localization/branding/auth qualify on stock fixtures, a persisted form is NOT proof",
+ "evidence": "the before/after consumer output"
+ },
+ {
+ "clause": "every write appends a sys_setting_audit row (namespace/key/scope/action='set'/source='api'/actor_id/new_hash)",
+ "oracle": "api",
+ "verify": "GET /api/v1/data/sys_setting_audit shows the row for localization.timezone (SettingsServicePlugin.buildAuditWriter)",
+ "evidence": "the audit row"
+ },
+ {
+ "clause": "the console source badge flips from default to the persisted-scope source after save",
+ "oracle": "screenshot",
+ "verify": "before/after field screenshots of the localization timezone source indicator",
+ "evidence": "the two screenshots"
+ },
+ {
+ "clause": "an OS_*-env-pinned key is server-authoritative: GET reports source='env' locked=true, and PUT is REFUSED 409 SETTINGS_LOCKED — the write refusal is the SERVER's, not the UI's",
+ "oracle": "api",
+ "verify": "the locked GET (source='env', lockedReason names OS_LOCALIZATION_TIMEZONE) + the 409 on the write (effectiveEnvOverride)",
+ "evidence": "the locked GET + the 409"
+ },
+ {
+ "clause": "the env-pinned field renders the EnvLockBadge and is non-editable in the console",
+ "oracle": "screenshot",
+ "verify": "screenshot of the locked field with the badge",
+ "evidence": "the screenshot"
+ },
+ {
+ "clause": "a declared test action does not fake success: POST /api/settings/mail/test with no deliverable transport answers ok:false (400 SETTINGS_ACTION_FAILED envelope) naming that no mail was sent",
+ "oracle": "api",
+ "verify": "the action response body (ok:false, message names the honest non-delivery — framework#5087)",
+ "evidence": "the action response"
+ },
+ {
+ "clause": "an encrypted specifier's value lands in sys_secret as a handle, never plaintext: sys_setting.value_enc holds a 'sec_' handle id and sys_secret holds the ciphertext; GET /api/settings never echoes the plaintext",
+ "oracle": "api",
+ "verify": "the sys_secret row (id starts 'sec_', ciphertext present) + sys_setting.value_enc = that handle + the redacted GET (materialiseRow dereferences through sys_secret)",
+ "evidence": "the sys_secret row + sys_setting.value_enc + the redacted GET"
+ },
+ {
+ "clause": "anonymous is denied: GET /api/settings/:namespace → 403 SETTINGS_FORBIDDEN, and GET /api/settings lists ZERO namespaces for an unauthenticated caller (no enumeration — Finding-1)",
+ "oracle": "api",
+ "verify": "the two anonymous responses",
+ "evidence": "the 403 + the empty list"
+ },
+ {
+ "clause": "each of the 10 open-framework namespaces resolves GET /api/settings/:ns with its manifest + values — no namespace 500s or serves an empty manifest",
+ "oracle": "api",
+ "verify": "one GET per variant in variants[]; each returns {manifest, values} with the manifest's specifiers",
+ "evidence": "the per-namespace responses"
+ }
+ ],
+ "negative": [
+ "a saved settings form treated as proof of effect (no observable consumer checked) is the ai.json anti-pattern — ticking the effect clause on the form alone is a FALSE PASS",
+ "a 200 on an env-locked PUT is a FAIL (the lock is a server contract, effectiveEnvOverride)",
+ "an encrypted value returned as plaintext by GET /api/settings, or stored inline in sys_setting.value rather than sys_secret, is a FAIL",
+ "a test action answering ok:true for a send that did not happen is a FAIL (framework#5087)",
+ "GET /api/settings enumerating namespaces for an anonymous caller is a FAIL (Finding-1 — the old default trusted x-user-id/x-permissions headers)"
+ ],
+ "variants": [
+ "localization",
+ "company",
+ "branding",
+ "auth",
+ "mail",
+ "sms",
+ "storage",
+ "ai",
+ "knowledge",
+ "feature_flags"
+ ],
+ "traps": ["stale-console-bundle", "dispatcher-vs-hono-route"],
+ "source": [
+ "packages/services/service-settings/src/settings-routes.ts (GET/PUT /api/settings, POST :ns/:actionId; 403 SETTINGS_FORBIDDEN / 409 SETTINGS_LOCKED / 400 SETTINGS_VALIDATION|UNKNOWN_KEY mapping; secure anonymous default)",
+ "packages/services/service-settings/src/settings-service.ts (cascade source default→scope; effectiveEnvOverride lock; encrypted→sys_secret handle via cryptoProvider+secretStore; validatePatch invalid_option)",
+ "packages/services/service-settings/src/settings-service-plugin.ts (verifiedContextFromRequest fail-closed; buildAuditWriter→sys_setting_audit; buildSecretStore→sys_secret; LocalCryptoProvider)",
+ "packages/services/service-settings/src/settings-service.types.ts (envKeyOf OS__)",
+ "packages/services/service-settings/src/manifests/{localization,mail,ai,branding}.manifest.ts",
+ "packages/core/src/security/resolve-authz-context.ts (localization timezone/locale/currency → ExecutionContext — the observable consumer)",
+ "framework#5087 (a test action must not fake success), #5204 (env override enforcement)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-08", "change": "initial — settings hub round-trip with persistence + observable-consumer effect (honoring the ai.json form-is-not-effect caveat), sys_setting_audit, env-lock server refusal, honest test action, sys_secret handle-not-plaintext, anonymous deny", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "platform-core.package-lifecycle-enable-disable",
+ "title": "Package lifecycle: disable stops serving, enable restores, uninstall de-registers, commits grow per publish and revert restores the prior published shape",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "mixed",
+ "personas": ["seeded admin (admin@objectos.ai / admin123)"],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "a runtime-created WRITABLE probe package — the SAME fixture platform-core.metadata-authoring-roundtrip creates via POST /api/v1/packages (reuse it, or create one in-run); author an object + a view + a nav entry into it and publish so it actually serves objects/nav/routes. The showcase's own code package is read-only by design (ADR-0070), so it cannot be the probe"
+ ],
+ "knownGaps": [
+ "if the deployment blocks runtime package install/enable (e.g. a locked-down prod profile), record blocked(fixture) — the lifecycle routes still exist (route-ledger.ts) but there is nothing writable to drive them against"
+ ]
+ },
+ "steps": [
+ "create or reuse the writable probe package (POST /api/v1/packages); author a probe object + view + nav entry into it and publish (POST /api/v1/packages/:id/publish or /publish-drafts). Record its id",
+ "confirm it serves: GET /api/v1/meta lists the probe object; GET /api/v1/data/ answers 200; the probe nav appears in the relevant /api/v1/meta/app",
+ "disable: PATCH /api/v1/packages/:id/disable → capture; then GET /api/v1/data/ and the probe routes → now 404/403 (stopped serving); /meta/app no longer lists its nav",
+ "enable: PATCH /api/v1/packages/:id/enable → capture; the object/nav/routes serve again (restore)",
+ "commits grow per publish: GET /api/v1/packages/:id/commits before and after a second publish — the list grows by exactly one entry; capture both listings",
+ "revert restores the prior published shape: change the probe (e.g. add a field) and publish, then POST /api/v1/packages/:id/commits/:commitId/revert (or POST /api/v1/packages/:id/revert) → GET the /published shape and confirm it is back to the prior commit",
+ "uninstall: DELETE /api/v1/packages/:id → capture; GET /api/v1/meta no longer lists the probe object/kinds and GET /api/v1/packages no longer lists the package",
+ "both-sides deny: attempt PATCH /disable and DELETE against a READ-ONLY code package (a platform package or the showcase's own) → refused server-side (ADR-0070); capture the refusal"
+ ],
+ "acceptance": [
+ {
+ "clause": "disable STOPS serving: after PATCH /packages/:id/disable, the package's objects/routes answer 404/403 and /meta/app drops its nav — disable is an enforcement, not a cosmetic flag",
+ "oracle": "api",
+ "verify": "GET /api/v1/data/ and the probe routes return 404/403 after disable (were 200 before)",
+ "evidence": "before/after reads"
+ },
+ {
+ "clause": "enable RESTORES: PATCH /packages/:id/enable and the same objects/nav/routes serve again",
+ "oracle": "api",
+ "verify": "the post-enable reads return 200 and the nav is back",
+ "evidence": "before/after reads"
+ },
+ {
+ "clause": "uninstall DE-REGISTERS: DELETE /packages/:id, then GET /meta no longer lists its kinds and GET /packages no longer lists it",
+ "oracle": "api",
+ "verify": "the meta + packages listings after DELETE",
+ "evidence": "the two listings"
+ },
+ {
+ "clause": "the commits list GROWS per publish: GET /packages/:id/commits gains exactly one entry per publish",
+ "oracle": "api",
+ "verify": "commit count after the second publish == count before + 1",
+ "evidence": "the two commit listings"
+ },
+ {
+ "clause": "revert RESTORES the prior published shape: POST /packages/:id/commits/:commitId/revert (or /revert) returns the published metadata to the prior commit, and a subsequent /published read matches the pre-change shape",
+ "oracle": "api",
+ "verify": "the pre-change /published read equals the post-revert /published read",
+ "evidence": "the pre/post published reads"
+ },
+ {
+ "clause": "the disabled package's surface is gone from the console — its nav entry is absent and a stale deep-link to its object shows a named not-found inside the shell, never a dead white page",
+ "oracle": "screenshot",
+ "verify": "screenshot the console after disable (nav absent) and a stale deep-link (named not-found in the shell)",
+ "evidence": "the two screenshots"
+ },
+ {
+ "clause": "DENY side: PATCH /disable and DELETE against a read-only code package are refused server-side (ADR-0070), not silently applied",
+ "oracle": "api",
+ "verify": "the refusal status/code on the read-only package",
+ "evidence": "the refusal"
+ },
+ {
+ "clause": "state is coherent across the cycle: a disabled-then-enabled package's DATA rows survive the toggle (disable stops serving, it does not destroy rows)",
+ "oracle": "api",
+ "verify": "row count before disable == row count after re-enable",
+ "evidence": "the two counts"
+ }
+ ],
+ "negative": [
+ "a disabled package still serving its objects/routes (200 on /data/) is a FAIL — disable is an enforcement, not a flag",
+ "uninstall leaving orphaned metadata in /meta (or the package still in GET /packages) is a FAIL",
+ "a revert that does not restore the prior published shape, or a commits list that grows without a working revert, is a FAIL",
+ "a 2xx on the read-only-package disable/uninstall attempt is a FAIL of the ADR-0070 gate"
+ ],
+ "traps": ["dispatcher-vs-hono-route", "stale-console-bundle"],
+ "source": [
+ "packages/runtime/src/route-ledger.ts (PATCH /packages/:id/enable|disable, DELETE /packages/:id, POST /packages/:id/publish|publish-drafts, GET /packages/:id/commits, POST /packages/:id/commits/:commitId/revert, POST /packages/:id/revert)",
+ "platform-core.metadata-authoring-roundtrip (the writable probe package this reuses)",
+ "ADR-0070 (writable vs read-only packages), ADR-0033 (drafts/publish/commits)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-08", "change": "initial — package enable/disable/uninstall + commits/revert lifecycle, driven against the writable probe package the authoring round-trip already creates", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "platform-core.notification-center",
+ "title": "Notification center (bell / InboxPopover): badge counts distinct unread topics + pending approvals, repeats coalesce, per-group and global mark-read drop the badge",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "mixed",
+ "personas": ["seeded admin (admin@objectos.ai / admin123)"],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "a recurring notification that produces many identical rows (the showcase_scheduled_digest firing once a minute — the exact flood inboxGrouping.ts was written for), so the topic-coalescing is exercised rather than assumed",
+ "at least one pending approval request (sys_approval_request / my_pending) so the approvals portion of the badge and the Approvals tab have something to show"
+ ],
+ "knownGaps": [
+ "rows without a notification_id (legacy/synthetic inbox rows) flip read optimistically but do NOT persist server-side — read-state lives in sys_notification_receipt keyed by the L2 event id (ADR-0030), so only rows carrying a notification_id survive reload. Verify persistence on a keyed row, and record synthetic rows as expected-non-persistent"
+ ]
+ },
+ "steps": [
+ "open the console as admin; the header (AppHeader.tsx) polls GET /api/v1/notifications?view=mine — capture the initial inbox rows",
+ "read the bell badge: it must equal distinct unread (topic,title) TOPICS + pending approvals, NOT raw row count (#2765) — the recurring digest producing many identical rows must not inflate it to 9+ off one topic",
+ "cross-check: count distinct unread (topic,title) groups from the payload (groupNotifications in inboxGrouping.ts) + pendingApprovalsCount and confirm it equals the rendered totalBadge",
+ "open the popover; confirm THREE tabs render: Notifications / Approvals / Activity",
+ "confirm coalescing: repeats of the same (topic,title) collapse into ONE expandable group with a ×N count pill; click the chevron to expand and reveal the members",
+ "per-group mark-read: click a group's 'Mark read' → capture POST /api/v1/notifications/read {ids:[...]} on the wire (ONE request for the whole group, not N); the group's unread dot clears and the badge drops by one topic",
+ "global mark-all-read: click 'Mark all read' → capture POST /api/v1/notifications/read/all; the notifications portion of the badge drops to 0; the approvals portion is unaffected",
+ "Approvals tab: confirm it lists/points at the pending approval request(s) and pendingApprovalsCount equals the sys_approval_request my_pending count",
+ "persistence: reload; read-state persists for rows carrying a notification_id (sys_notification_receipt upsert, ADR-0030); synthetic rows without one flip only optimistically (knownGap)"
+ ],
+ "acceptance": [
+ {
+ "clause": "the badge counts distinct unread TOPICS + pending approvals, not raw rows — a recurring digest producing N identical rows contributes 1 to the badge (#2765)",
+ "oracle": "network",
+ "verify": "distinct-(topic,title) unread group count from the GET /notifications payload + pendingApprovalsCount == the rendered totalBadge",
+ "evidence": "the payload + the badge value"
+ },
+ {
+ "clause": "three tabs render (Notifications / Approvals / Activity)",
+ "oracle": "screenshot",
+ "verify": "the popover shows all three TabsTrigger surfaces",
+ "evidence": "popover screenshot"
+ },
+ {
+ "clause": "(topic,title) repeats COALESCE into one expandable group with a ×N pill; expanding reveals the members (a group of one renders as a plain row, no pill)",
+ "oracle": "dom",
+ "verify": "after the screenshot confirms the popover rendered, assert the group row carries the ×N pill and the expanded list holds N members",
+ "evidence": "collapsed + expanded screenshots + DOM excerpt"
+ },
+ {
+ "clause": "per-group mark-read issues POST /api/v1/notifications/read {ids} as a SINGLE request for the whole group (not one per row), and the group's unread state clears",
+ "oracle": "network",
+ "verify": "the captured POST carries the group's member ids in one body; the badge drops by one topic",
+ "evidence": "the captured POST + before/after badge"
+ },
+ {
+ "clause": "global mark-all-read issues POST /api/v1/notifications/read/all and drops the notifications portion of the badge to 0",
+ "oracle": "network",
+ "verify": "the captured POST + the badge's notifications portion at 0 afterward",
+ "evidence": "the captured POST + badge"
+ },
+ {
+ "clause": "the Approvals tab lists the pending request(s) and pendingApprovalsCount equals the sys_approval_request my_pending count",
+ "oracle": "dom",
+ "verify": "the tab content + the count vs a direct sys_approval_request my_pending query",
+ "evidence": "the tab + the count comparison"
+ },
+ {
+ "clause": "read-state PERSISTS across reload for rows carrying a notification_id (sys_notification_receipt upsert, ADR-0030) — after mark-read + reload the keyed row stays read; a mark-read that reverts on the next poll is the pre-ADR-0030 receipt bug",
+ "oracle": "network",
+ "verify": "the post-reload GET /notifications shows the keyed row still read",
+ "evidence": "the post-reload payload"
+ }
+ ],
+ "negative": [
+ "a badge showing raw unread ROW count (inflating to 9+ off one recurring topic) is the #2765 regression — FAIL",
+ "mark-read firing one POST per row for a coalesced group (instead of one {ids} request) is a FAIL of the coalescing contract",
+ "read-state reverting to unread on the next poll for a keyed row (writing sys_notification_receipt through the generic data API, which rejects it — ADR-0103) is a FAIL",
+ "cross-ref: this is distinct from i18n.notification-localized-and-clears (single-entry localization + clear) and approvals.notification-deep-link (deep-link) — do not double-count their coverage here"
+ ],
+ "traps": ["hydration-race", "single-datapoint"],
+ "source": [
+ "objectui packages/app-shell/src/layout/InboxPopover.tsx (totalBadge = unreadTopics + pendingApprovalsCount; three tabs; per-group markGroupRead + global onMarkAllRead)",
+ "objectui packages/app-shell/src/layout/inboxGrouping.ts (groupNotifications — (topic,title) coalescing, #2765)",
+ "objectui packages/app-shell/src/layout/AppHeader.tsx (postMarkRead → POST /api/v1/notifications/read[/all]; poll GET /notifications?view=mine)",
+ "packages/runtime/src/route-ledger.ts (GET /notifications, POST /notifications/read, POST /notifications/read/all)",
+ "packages/runtime/src/domains/notifications.ts (markRead upserts sys_notification_receipt; empty/mis-keyed ids → 400)",
+ "ADR-0030 (sys_inbox_message L5 materialization + sys_notification_receipt); ADR-0103 (receipt object api-method lockdown)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-08", "change": "initial — bell/InboxPopover: topic-based badge (#2765), (topic,title) coalescing, per-group + global mark-read on the wire, approvals tab, receipt persistence; cross-referenced with i18n + approvals notification items", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "platform-core.shell-nav-personalization",
+ "title": "Shell nav personalization: sidebar collapse, pin/reorder, favorites, recents and header breadcrumbs each persist and stay navigable across reload",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "browser",
+ "personas": ["seeded admin (admin@objectos.ai / admin123)"],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "stock showcase nav (multiple objects/dashboards/reports so there is something to pin, reorder, favorite, and visit as recents)"
+ ]
+ },
+ "steps": [
+ "sidebar collapse: toggle the sidebar to icon (collapsed) mode; confirm nav items still NAVIGATE in icon mode; reload — the collapsed state persists (cookie sidebar_state, packages/components/src/ui/sidebar.tsx SIDEBAR_COOKIE_NAME)",
+ "nav pin/unpin: pin a nav item (UnifiedSidebar → useNavPins.togglePin → favorite id nav:, backed by FavoritesProvider/UserDataAdapter); it appears in the Pinned section; reload — the pin persists (syncs via UserDataAdapter; migrates the legacy objectui-nav-pins key); unpin removes it",
+ "drag-reorder: reorder nav items by drag (UnifiedSidebar useNavOrder, enableReorder / onReorder=handleReorder); reload — the order persists (localStorage per-app key)",
+ "favorites star/unstar: star an object/record as a favorite (useFavorites / FavoritesProvider); it appears in favorites; reload — persists; unstar removes it",
+ "recents rail: visit several records/objects/dashboards; the sidebar Recent section (collapsed by default, top 5 — useTrackRouteAsRecent → RecentItemsProvider, localStorage objectui-recent-items scoped by userId, hydrates UserDataAdapter) lists them; reload — recents survive",
+ "header record-trail breadcrumbs: on a record detail the AppHeader breadcrumb shows the app/section/record trail; click a crumb to navigate back to its level; reload — the trail rebuilds from the route",
+ "record which persistence each feature uses: pins/favorites/recents are UserDataAdapter-backed (cross-device sync), reorder + collapse are local (localStorage / cookie)"
+ ],
+ "acceptance": [
+ {
+ "clause": "collapsing the sidebar to icon mode still NAVIGATES — clicking an icon routes correctly — and the collapsed state survives reload (cookie sidebar_state)",
+ "oracle": "screenshot",
+ "verify": "collapsed screenshot + a nav click that routes + the collapsed state present after reload",
+ "evidence": "collapsed screenshot + post-reload state + the routed click"
+ },
+ {
+ "clause": "pin/unpin: a pinned nav item appears in the Pinned section and survives reload; unpin removes it (useNavPins → nav: favorite)",
+ "oracle": "dom",
+ "verify": "after a screenshot confirms render, assert the pinned item present pre- and post-reload; absent after unpin",
+ "evidence": "pre/post-reload DOM"
+ },
+ {
+ "clause": "drag-reorder persists: the reordered nav order is restored after reload (useNavOrder localStorage)",
+ "oracle": "dom",
+ "verify": "the nav item order before reload equals the order after reload",
+ "evidence": "order before/after reload"
+ },
+ {
+ "clause": "favorites star/unstar persists across reload",
+ "oracle": "dom",
+ "verify": "the starred item is present in favorites before and after reload; gone after unstar",
+ "evidence": "pre/post-reload favorites list"
+ },
+ {
+ "clause": "the Recents rail lists recently-visited entities (top 5) and survives reload (RecentItemsProvider)",
+ "oracle": "dom",
+ "verify": "the visited entities appear in the Recent section after reload",
+ "evidence": "visit sequence + post-reload recents"
+ },
+ {
+ "clause": "header record-trail breadcrumbs render on a record and each crumb navigates back to its level; the trail rebuilds after reload",
+ "oracle": "screenshot",
+ "verify": "breadcrumb screenshot + a crumb navigation that routes back + the trail present after reload",
+ "evidence": "breadcrumb screenshot + the crumb navigation"
+ }
+ ],
+ "negative": [
+ "a collapsed sidebar whose icon items no longer navigate (dead icon mode) is a FAIL",
+ "a pin / favorite / reorder / recent that does NOT survive reload is a FAIL of its persistence contract",
+ "a breadcrumb crumb that is inert (does not navigate) is a FAIL",
+ "transitional emptiness right after navigation (empty nav/recents) must be ruled out by the screenshot-first protocol before any persistence FAIL is recorded"
+ ],
+ "traps": ["hydration-race", "shared-browser-tab"],
+ "source": [
+ "objectui packages/app-shell/src/layout/UnifiedSidebar.tsx (useNavOrder drag-reorder localStorage; Recent section; applyPins)",
+ "objectui packages/app-shell/src/hooks/useNavPins.ts (togglePin → nav: favorite, MAX_PINS 20, UserDataAdapter-backed, legacy objectui-nav-pins migration)",
+ "objectui packages/app-shell/src/hooks/useFavorites.ts + context/FavoritesProvider.tsx (favorites state)",
+ "objectui packages/app-shell/src/context/RecentItemsProvider.tsx + hooks/useTrackRouteAsRecent.ts (recents — objectui-recent-items localStorage + UserDataAdapter hydrate)",
+ "objectui packages/app-shell/src/layout/AppHeader.tsx (breadcrumb record-trail)",
+ "objectui packages/components/src/ui/sidebar.tsx (SIDEBAR_COOKIE_NAME sidebar_state — collapse persistence)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-08", "change": "initial — sidebar collapse/icon-mode, nav pin+reorder, favorites, recents rail, header breadcrumbs, each with its real persistence layer (cookie/localStorage/UserDataAdapter)", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ },
+ {
+ "id": "platform-core.app-management-toggle",
+ "title": "App management: the launcher/App Switcher filters by active+hidden and isDefault drives post-login landing — but the AppManagementPage toggle is a client-only stub today",
+ "since": "v15",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "mixed",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)",
+ "a plain member (fresh runtime sign-up — member_default)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "a writable package to author an app overlay into (POST /api/v1/packages) so the active/isDefault EFFECT can be exercised at the metadata layer — the seeded showcase/platform apps are read-only code packages (ADR-0070)"
+ ],
+ "knownGaps": [
+ "AppManagementPage's Enable/Disable/Set-default/Delete/Bulk handlers are CLIENT-ONLY STUBS today (objectui apps/console/src/pages/system/AppManagementPage.tsx — every handler is a toast.success() + refresh() carrying a 'TODO: Replace with real API call when backend supports app management' marker; no request is issued and no metadata changes). So the page's toggle CANNOT prove the effect — the launcher/landing behavior is exercised at the metadata layer instead, and the stub is recorded as an expected-fail probe (RUNNER rule 3: a stub that reports success is the finding)"
+ ]
+ },
+ "steps": [
+ "establish the launcher filter is real: the App Switcher (objectui AppSwitcher.tsx) lists only apps with active !== false && hidden !== true. Confirm the Account app (hidden:true — account.app.ts) is ABSENT from the switcher while reachable via the avatar dropdown; Setup/Studio/showcase present",
+ "prove DISABLE-effect at the metadata layer (the UI toggle is a stub): author an app overlay with active:false into a writable package (POST /api/v1/packages + PUT /api/v1/meta/app/ active:false, publish) → confirm it leaves the App Switcher/launcher for end users (absent); flip active:true → it returns",
+ "prove DEFAULT→landing: the isDefault app drives post-login landing — confirm login lands on the current default app, and that changing which app is isDefault (at the metadata layer) changes the post-login landing route",
+ "member view: as a plain member the App Switcher shows only accessible apps (Setup/Studio absent by App.requiredPermissions); disabled/hidden apps also absent",
+ "open Setup → Apps → Applications (AppManagementPage) as admin; it lists configured apps with Active/Default badges and per-row controls",
+ "EXPECTED-FAIL / known-stub probe: click Disable on a seeded app in AppManagementPage → a success toast appears BUT no request is issued (empty network) and GET /api/v1/meta/app shows the app still active on reload. Record the toast-without-persistence; do NOT credit the app as disabled off the toast",
+ "same probe for Set-default and Delete: the toast fires, the metadata is unchanged on reload"
+ ],
+ "acceptance": [
+ {
+ "clause": "the App Switcher lists only active, non-hidden apps: hidden apps (Account) and inactive apps are absent; active apps present (AppSwitcher.tsx activeApps = active !== false && hidden !== true)",
+ "oracle": "dom",
+ "verify": "after a screenshot confirms the switcher rendered, diff its entries against the served /meta/app list — Account (hidden) absent, active apps present",
+ "evidence": "switcher DOM vs the served app list"
+ },
+ {
+ "clause": "disabling an app removes it from the launcher for end users — proven by setting active:false at the metadata layer (writable-package overlay), then confirming absence; re-enable returns it",
+ "oracle": "screenshot",
+ "verify": "before/after App Switcher screenshots bracketing the active:false and active:true meta writes",
+ "evidence": "before/after switcher screenshots + the meta writes"
+ },
+ {
+ "clause": "the default app drives post-login landing: login lands on the isDefault app, and changing the default changes the landing route",
+ "oracle": "screenshot",
+ "verify": "post-login screenshots for two different isDefault choices land on different apps",
+ "evidence": "the two post-login screenshots"
+ },
+ {
+ "clause": "a member sees only accessible apps in the switcher (Setup/Studio absent by requiredPermissions; disabled/hidden absent)",
+ "oracle": "dom",
+ "verify": "the member's switcher DOM excludes Setup/Studio and any disabled/hidden app",
+ "evidence": "the member switcher DOM"
+ },
+ {
+ "clause": "AppManagementPage renders the Applications list with Active/Default badges and per-row controls",
+ "oracle": "screenshot",
+ "verify": "the page renders the app cards with the Active/Default/Inactive badges",
+ "evidence": "the page screenshot"
+ },
+ {
+ "clause": "EXPECTED-FAIL / known-stub probe — AppManagementPage's Disable/Set-default/Delete are client-only stubs: clicking Disable issues NO request (empty network) and GET /api/v1/meta/app is UNCHANGED on reload, while a success toast is shown. A run records the stub and MUST NOT tick 'disabled for end users' off the toast",
+ "oracle": "network",
+ "verify": "the network trace shows no PATCH/DELETE on the click; /meta/app unchanged on reload; the toast fired (AppManagementPage TODO handlers)",
+ "evidence": "the absent request + the unchanged meta + the toast"
+ }
+ ],
+ "negative": [
+ "ticking 'app disabled' off the AppManagementPage success toast is a FALSE PASS — the handler is a stub (no backend), the effect must be proven at the metadata/launcher layer",
+ "the App Switcher showing a hidden (Account) or inactive app to end users is a FAIL of the launcher filter",
+ "post-login landing that ignores isDefault is a FAIL"
+ ],
+ "traps": ["wrong-persona", "stale-console-bundle", "hydration-race"],
+ "source": [
+ "objectui apps/console/src/pages/system/AppManagementPage.tsx (the stubbed Enable/Disable/Set-default/Delete/Bulk handlers — TODO 'when backend supports app management')",
+ "objectui packages/app-shell/src/layout/AppSwitcher.tsx (activeApps = active !== false && hidden !== true)",
+ "packages/platform-objects/src/apps/account.app.ts (hidden:true example)",
+ "packages/runtime/src/route-ledger.ts (PUT /meta/app/:name, POST /packages/:id/publish — the metadata layer that actually changes app active/default)",
+ "ADR-0048 (app package routing / launcher)"
+ ],
+ "history": [
+ { "revision": 1, "date": "2026-08-08", "change": "initial — launcher active+hidden filter and isDefault→landing proven at the metadata layer, with the AppManagementPage toggle recorded as a client-only stub (expected-fail probe) rather than faked", "ref": "claude/platform-test-checklist-ocwugl" }
+ ]
+ }
+ ]
+}
diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json
new file mode 100644
index 0000000000..4b42df13a1
--- /dev/null
+++ b/docs/qa/platform-checklist/areas/records-forms.json
@@ -0,0 +1,3195 @@
+{
+ "$comment": "Standing platform test checklist — area ledger. Hand-edited, append-only; validated by scripts/check-platform-checklist.mjs (pnpm check:platform-checklist). Authoring rules: docs/qa/platform-checklist/README.md · execution protocol: RUNNER.md.",
+ "area": "records-forms",
+ "title": "Records, lists, detail pages, forms",
+ "items": [
+ {
+ "id": "records-forms.crud-roundtrip",
+ "title": "Create → read → update → delete a record through the console UI",
+ "since": "v15",
+ "status": "active",
+ "revision": 3,
+ "priority": "P0",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_account — writable standard object (sharingModel public_read_write), required name + status, format validations tax_id_format / billing_email_format (examples/app-showcase/src/data/objects/account.object.ts)"
+ ]
+ },
+ "steps": [
+ "sign in as seeded admin; open Accounts via its nav entry (/_console/apps/com.example.showcase/showcase_account)",
+ "click New; fill name with a distinctive value (os-qa-), industry, status=active; leave website empty; Save",
+ "capture the create request/response (expect POST /api/v1/data/showcase_account)",
+ "re-read server-side: GET /api/v1/data/showcase_account?$filter=[[\"name\",\"=\",\"os-qa-\"]] — field-by-field vs the submitted payload",
+ "open the record detail; edit exactly one field (annual_revenue); Save; capture the PATCH /api/v1/data/showcase_account/; re-read the full row via GET /api/v1/data/showcase_account/",
+ "open the record's History tab (screenshot first, then read entries)",
+ "clone the record: POST /api/v1/data/showcase_account//clone (optionally with {overrides:{name:'os-qa--clone'}}); capture the 201 result (new id ≠ source, sourceId, record); re-read the clone via GET /api/v1/data/showcase_account/",
+ "RLS probe on clone: as a persona WITHOUT read on the source row (a permission-zoo persona, or a non-owner under owner-RLS), POST the same clone route and capture the refusal — expect 404 RECORD_NOT_FOUND, never a silent duplicate (record blocked(fixture) if no non-reading persona is bound on this boot)",
+ "delete via the row/detail action; confirm the dialog; re-read via the filtered GET",
+ "reload the list view and confirm final state"
+ ],
+ "acceptance": [
+ {
+ "clause": "create returns success with an id and the API re-read shows every submitted value verbatim (no silent coercion/loss on any field)",
+ "oracle": "api",
+ "verify": "field-by-field diff of the POST payload vs the filtered GET re-read; the empty optional (website) stays empty, not defaulted",
+ "evidence": "create payload + re-read JSON"
+ },
+ {
+ "clause": "the update persists ONLY the edited field — untouched fields byte-identical across before/after full-row reads",
+ "oracle": "api",
+ "verify": "diff of GET /api/v1/data/showcase_account/ before and after the single-field annual_revenue edit",
+ "evidence": "the two full-row reads"
+ },
+ {
+ "clause": "delete removes the row authoritatively — the filtered API re-read returns 0 rows and the reloaded list no longer shows it",
+ "oracle": "api",
+ "verify": "GET ...?$filter=[[\"name\",\"=\",\"os-qa-\"]] returns total 0 post-delete",
+ "evidence": "the empty read + post-reload screenshot"
+ },
+ {
+ "clause": "the list reflects each mutation after a full reload with correct display values (grid repaint is NOT the oracle — the reload is)",
+ "oracle": "screenshot",
+ "verify": "post-reload screenshots of the list at create and at delete",
+ "evidence": "screenshots"
+ },
+ {
+ "clause": "record History reflects the create and the update with display values (option labels, localized dates, real actor), not raw audit payloads or phantom value→null rows",
+ "oracle": "dom",
+ "verify": "after a screenshot confirms the History tab rendered, read its entries — same contract objectui e2e/live/record-history-display.spec.ts pins on showcase Project",
+ "evidence": "screenshot + entries"
+ },
+ {
+ "clause": "unicode round-trips: a create with a CJK name (e.g. os-qa--华宁) reads back byte-identical and is findable via list quick-search",
+ "oracle": "api",
+ "verify": "filtered GET returns the CJK name unmangled; $search finds it (the seed's 华宁科技 proves the pattern — ADR-0061)",
+ "evidence": "re-read JSON + search response"
+ },
+ {
+ "clause": "clone (POST /data/:object/:id/clone, gated by enable.clone default-on) returns 201 with a NEW id and the source's field VALUES copied, but engine-owned columns (id, audit, autonumber, formula, summary) and readonly columns (e.g. approval_status) RE-DERIVED not carried, and the clone is owned by the CLONER — not the source's owner",
+ "oracle": "api",
+ "verify": "the 201 result carries {id (new ≠ sourceId), sourceId, record}; field-by-field diff shows business values copied and system/readonly columns re-derived (#3043 CLONE_STRIP_FIELDS + stripReadonlyForInsert); owner_id resolves to the signed-in cloner (the clone is a create in the caller's context — packages/metadata-protocol/src/protocol.ts cloneData)",
+ "evidence": "clone response + source-vs-clone field diff + owner_id read"
+ },
+ {
+ "clause": "clone is RLS-gated: the source is fetched in the caller's context (engine.findOne with context), so cloning a record the caller cannot SEE is refused with 404 RECORD_NOT_FOUND — never a silent duplicate of an invisible row",
+ "oracle": "api",
+ "verify": "as a persona without read on the source row, POST the clone route → 404 RECORD_NOT_FOUND and no new row lands (recordNotFoundError from the null findOne); an enable.clone:false object refuses with 403 CLONE_DISABLED",
+ "evidence": "the RLS refusal + a post-attempt count showing no new row"
+ }
+ ],
+ "negative": [
+ "save with required status empty → the form blocks with a named field error AND no row is created (filtered API count stays 0) — a silent success is a FAIL",
+ "a clone that carries over the source's id / audit columns / an autonumber / a readonly approval_status instead of re-deriving them is a FAIL (#3043 — a clone must not mint an already-approved record); a clone of an RLS-invisible source that SUCCEEDS (200/201 with a new row) is a FAIL — the findOne runs in the caller's context precisely to refuse it",
+ "a direct API POST missing required status → 400 VALIDATION_FAILED with fields[] carrying code 'required' (server enforces, not just the form — packages/objectql/src/validation/record-validator.ts)",
+ "a create with tax_id violating the tax_id_format rule → named validation error, never a silently-stored bad value"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input"
+ ],
+ "source": [
+ "dogfood-verification skill §3",
+ "examples/app-showcase/src/data/objects/account.object.ts (requiredness + format/conditional validations)",
+ "packages/runtime/src/route-ledger.ts (/data CRUD routes)",
+ "packages/rest/src/rest-server.ts (POST /data/:object/:id/clone → registerDataActionEndpoints) + packages/metadata-protocol/src/protocol.ts (cloneData: enable.clone gate, findOne-in-caller-context, CLONE_STRIP_FIELDS, stripReadonlyForInsert)",
+ "packages/rest/src/rest-route-ledger.ts:122 (POST /api/v1/data/:object/:id/clone, client data.clone)",
+ "objectui: e2e/live/record-history-display.spec.ts",
+ "cross-ref: the inline-edit atomic two-surface behavior (ONE Save bar / ONE PATCH carrying exactly the changed keys + ifMatch) is folded into records-forms.concurrent-edit-conflict, not here"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — standing P0 smoke",
+ "ref": "#3358"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 3,
+ "date": "2026-08-08",
+ "change": "added the /data/:object/:id/clone clauses (new id + copied field values, cloner ownership, engine/readonly column re-derivation, RLS-invisible source refused RECORD_NOT_FOUND, enable.clone gate); inline-edit two-surface behavior placed in concurrent-edit-conflict with a cross-ref here",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.field-type-matrix",
+ "title": "Field-type matrix: every FieldTypeSchema member renders its widget, accepts a valid value, round-trips over HTTP, and rejects invalid input",
+ "since": "v15",
+ "status": "active",
+ "revision": 2,
+ "priority": "P1",
+ "surface": "mixed",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_field_zoo — one field of (almost) every FieldType (examples/app-showcase/src/data/objects/field-zoo.object.ts); seeded rows 'Specimen — Full' and 'Specimen — Minimal'"
+ ],
+ "knownGaps": [
+ "summary is NOT on the zoo (a roll-up needs a child object and the zoo is a leaf) — verify it on its real carriers showcase_invoice.total and showcase_expense_report.total_amount/approved_amount instead",
+ "f_user / f_users / f_owner cannot be seeded or written on a fresh boot with no signed-up users (sys_user rows come from sign-up, not seeds) — assign after creating a user, or record blocked(fixture)",
+ "f_secret writes fail closed unless an ICryptoProvider is registered (ADR-0100) — the seed deliberately omits it; on a stock boot verify the masked-read contract via the dogfood pin, not a raw UI write"
+ ]
+ },
+ "variants": [
+ "text",
+ "textarea",
+ "email",
+ "url",
+ "phone",
+ "password",
+ "secret",
+ "markdown",
+ "html",
+ "richtext",
+ "number",
+ "currency",
+ "percent",
+ "date",
+ "datetime",
+ "time",
+ "boolean",
+ "toggle",
+ "select",
+ "multiselect",
+ "radio",
+ "checkboxes",
+ "lookup",
+ "master_detail",
+ "tree",
+ "user",
+ "image",
+ "file",
+ "avatar",
+ "video",
+ "audio",
+ "formula",
+ "summary",
+ "autonumber",
+ "composite",
+ "repeater",
+ "record",
+ "location",
+ "address",
+ "code",
+ "json",
+ "color",
+ "rating",
+ "slider",
+ "signature",
+ "qrcode",
+ "progress",
+ "tags",
+ "vector"
+ ],
+ "steps": [
+ "run the pinned HTTP round-trip matrix: pnpm --filter @objectstack/dogfood exec vitest run test/field-zoo-roundtrip.dogfood.test.ts (write vectors + expected read shapes live in test/field-zoo.matrix.ts)",
+ "boot showcase isolated; open Field Zoo (/_console/apps/com.example.showcase/showcase_field_zoo) and open 'Specimen — Full' in edit mode; screenshot the form before reading DOM",
+ "enumerate the rendered control for every f_* field and build a variant→widget table (date→date input, color→color input, richtext→editor, select/multiselect→pickers, image/file→upload, code→code editor, location/address→structured inputs, rating/slider/progress→their own controls, autonumber/formula→read-only)",
+ "through the form, change one representative value per family (a select, an array type, a temporal, a structured JSON), Save, and capture the PATCH",
+ "re-read the record via GET /api/v1/data/showcase_field_zoo/ and diff against the submitted values (arrays compared as sets)",
+ "POST an out-of-set select value (f_select: 'not-a-value') directly to /api/v1/data/showcase_field_zoo and capture the refusal",
+ "POST f_lookup with a fabricated account id and capture the refusal (#4441 dangling-reference gate)",
+ "verify f_formula and f_autonumber materialized server-side on a created row (formula = f_number × f_percent / 100)"
+ ],
+ "acceptance": [
+ {
+ "clause": "every authorable field type in the matrix survives a real HTTP POST → GET round-trip with its declared value shape (arrays as sets; JSON object types as objects, not stringified)",
+ "oracle": "test",
+ "verify": "pnpm --filter @objectstack/dogfood exec vitest run test/field-zoo-roundtrip.dogfood.test.ts — green, with any it.fails (xfail) rows reported as the known type-fidelity gaps they are",
+ "evidence": "test run output"
+ },
+ {
+ "clause": "PER-VARIANT: each of the 49 FieldTypeSchema members renders its real widget on the Specimen — Full form (not a generic text input), with per-variant evidence recorded in the variant→widget table",
+ "oracle": "dom",
+ "verify": "after the screenshot confirms render, match each f_* control against its declared type; every variant row in the table carries its own observed control + screenshot crop",
+ "evidence": "form screenshot + the 49-row variant→widget table"
+ },
+ {
+ "clause": "constrained types reject invalid input server-side with a named error: out-of-set select → VALIDATION_FAILED with fields[].code 'invalid_option'; missing required name → code 'required'",
+ "oracle": "api",
+ "verify": "direct POSTs with the bad payloads return 400-class VALIDATION_FAILED envelopes naming the field; the row count does not grow",
+ "evidence": "refusal responses + before/after counts"
+ },
+ {
+ "clause": "credential types mask on read: f_secret and f_password never echo plaintext — reads return the SECRET_MASK sentinel",
+ "oracle": "api",
+ "verify": "the dogfood matrix 'masked' checks (kind: 'masked' in test/field-zoo.matrix.ts) pass; any GET of the record shows the mask, not the written value",
+ "evidence": "test output + a raw GET excerpt"
+ },
+ {
+ "clause": "computed/system types materialize without being written: f_autonumber is server-assigned and non-null; f_formula reads f_number × f_percent / 100 (matrix vector: 42 × 75 / 100 = 31.5)",
+ "oracle": "api",
+ "verify": "create via the matrix suite or by hand and read both fields back; the formula value matches the arithmetic",
+ "evidence": "the read JSON"
+ },
+ {
+ "clause": "relational types (lookup / master_detail / tree) store a real reference id verbatim and refuse a dangling one",
+ "oracle": "api",
+ "verify": "matrix REFERENCE_TARGETS rows (showcase_account / showcase_project / showcase_category) round-trip their created ids; a fabricated id is rejected (#4441)",
+ "evidence": "test output + the dangling-id refusal"
+ },
+ {
+ "clause": "summary is verified on its real carriers: showcase_invoice.total sums its lines server-side; showcase_expense_report shows the summaryOperations.filter variant (approved_amount ≠ total_amount on seeded EXP-2001)",
+ "oracle": "api",
+ "verify": "GET the seeded invoices/expense reports and check the roll-up columns against the seeded line arithmetic (EXP-2001: total 1500.50, approved 960 — seed comments carry the expected values)",
+ "evidence": "the reads + the seed-derived expected table"
+ }
+ ],
+ "negative": [
+ "an out-of-set select value accepted with 200 is a FAIL (the server, not the picker, is the boundary)",
+ "a lookup/master_detail/tree write pointing at a nonexistent row accepted with 200 is a FAIL (#4441 closed exactly this hole)",
+ "any GET returning f_secret/f_password plaintext is a FAIL regardless of what the form shows"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input",
+ "stale-console-bundle"
+ ],
+ "automated": {
+ "kind": "api",
+ "ref": "packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts (+ field-zoo.matrix.ts vectors, field-zoo-value-shape.test.ts contract)"
+ },
+ "source": [
+ "packages/spec/src/data/field.zod.ts (FieldType enum — 49 members, listed exhaustively in variants)",
+ "examples/app-showcase/src/data/objects/field-zoo.object.ts",
+ "examples/app-showcase/src/data/seed/index.ts (Specimen rows; expense/invoice roll-up expectations)",
+ "packages/qa/dogfood/test/field-zoo.matrix.ts",
+ "objectui: e2e/live/summary-rollup.spec.ts"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — deep-test variant matrix over FieldTypeSchema, pinned to the dogfood HTTP round-trip suite",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-08",
+ "change": "pinned enumSource for the variants-freshness ratchet — spec enum drift is caught by the manual check on this item directly",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ],
+ "enumSource": {
+ "file": "packages/spec/src/data/field.zod.ts",
+ "export": "FieldType",
+ "expect": 49
+ }
+ },
+ {
+ "id": "records-forms.list-view-capabilities",
+ "title": "List-view capability matrix: filter, search, sort, pagination, saved views, inline edit, export, visualization switcher, bulk/row actions, conditional formatting",
+ "since": "v15",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_task with 10 seeded tasks (2 in_progress) and the saved views in examples/app-showcase/src/ui/views/task.view.ts (in_progress/urgent/done, tabular with string sort, grid with inlineEdit, bulk_actions, legacy_row_actions)",
+ "showcase_field_zoo views for conditional formatting + bulk-action gating (examples/app-showcase/src/ui/views/field-zoo.view.ts)",
+ "CJK seeds for search recall (account 华宁科技, contact 张伟)"
+ ]
+ },
+ "variants": [
+ "saved-view-filter",
+ "quick-search",
+ "sort",
+ "server-pagination",
+ "saved-view-switcher",
+ "inline-edit",
+ "export",
+ "visualization-switcher",
+ "bulk-actions",
+ "row-actions",
+ "conditional-formatting"
+ ],
+ "steps": [
+ "open /_console/apps/com.example.showcase/showcase_task and screenshot the default All Tasks grid",
+ "switch to the in_progress saved view; capture the data request — expect $filter carrying the stored rule as an ObjectQL AST ([[\"status\",\"equals\",\"in_progress\"]]) and a 200; the count bar must read 2",
+ "quick-search 'huaning' on Accounts (or '张' on Contacts); capture the $search request and the hits (pinyin + CJK recall, ADR-0061)",
+ "open the tabular saved view (string-form sort 'estimate_hours desc' — the objectui#2601 fixture); confirm it renders and the request carries the sort",
+ "page a list past page 1; capture $top/$skip on every data request; confirm no unpaged full fetch",
+ "in the grid saved view (inlineEdit: true), edit one cell in place; save; capture the PATCH; re-read the row via API",
+ "on in_progress, export each declared format (csv, xlsx, json); capture the requests and files",
+ "open the visualization switcher on All Tasks; enumerate the offered types; switch through each",
+ "on bulk_actions, select rows and run showcase_mark_done (per-record) and showcase_recalc_selection (aggregate — ONE dispatch carrying params._selectedIds); on legacy_row_actions confirm both named row actions resolve to live menu entries",
+ "on Field Zoo, confirm the conditionalFormatting row highlight applies to Specimen — Full (f_lookup set) and not Specimen — Minimal"
+ ],
+ "acceptance": [
+ {
+ "clause": "PER-VARIANT: every listed capability variant is exercised and carries its own captured evidence (request trace or screenshot) — no variant ticked by association",
+ "oracle": "network",
+ "verify": "one evidence artifact per variant in the run record, keyed by variant name",
+ "evidence": "per-variant trace/screenshot set"
+ },
+ {
+ "clause": "a saved view's stored filter reaches $filter as an ObjectQL AST, the server answers 200, and the filtered count is server-computed (2 of 10 for in_progress)",
+ "oracle": "network",
+ "verify": "captured GET /api/v1/data/showcase_task?...$filter=[[\"status\",\"equals\",\"in_progress\"]] → 200; count bar matches the response total",
+ "evidence": "request/response trace + screenshot"
+ },
+ {
+ "clause": "quick-search issues a server-side $search (not client filtering) and finds the CJK account via pinyin ('huaning' → 华宁科技)",
+ "oracle": "api",
+ "verify": "the captured request carries $search; the response contains the CJK row; the same query direct against GET /api/v1/data/showcase_account reproduces it",
+ "evidence": "request trace + response JSON"
+ },
+ {
+ "clause": "paging issues $top/$skip requests; the full set is never fetched; a walked page sequence visits every row exactly once (deterministic paging)",
+ "oracle": "network",
+ "verify": "page 1 = $top=N; page 2 = $top=N&$skip=N; union of pages has no duplicate and no missing id",
+ "evidence": "the request URLs + the id-union check"
+ },
+ {
+ "clause": "string-form sort ('estimate_hours desc') renders without error and the rows come back server-ordered (the objectui#2601 crash fixture stays green)",
+ "oracle": "network",
+ "verify": "the tabular view request succeeds; response row order is by estimate_hours descending",
+ "evidence": "trace + first-page rows"
+ },
+ {
+ "clause": "inline edit persists through the API — the PATCH carries only the edited cell and a post-reload re-read shows it, with untouched fields unchanged",
+ "oracle": "api",
+ "verify": "diff full-row reads before/after the cell edit (same contract objectui e2e/live/inline-edit-polish-2572.spec.ts pins on Project)",
+ "evidence": "the two reads + the PATCH body"
+ },
+ {
+ "clause": "export offers exactly the declared formats (csv, xlsx, json on in_progress) and the exported rows equal the FILTERED set, not the whole table",
+ "oracle": "network",
+ "verify": "the export menu lists the three exportOptions; each downloaded file contains the 2 in_progress rows",
+ "evidence": "menu screenshot + the three files"
+ },
+ {
+ "clause": "the visualization switcher offers exactly the whitelisted six (grid, kanban, gallery, calendar, timeline, gantt — appearance.allowedVisualizations) and each re-renders the SAME task records",
+ "oracle": "dom",
+ "verify": "after screenshot, enumerate the dropdown entries; switch to each and confirm records render (map/chart are named views, correctly NOT in the switcher)",
+ "evidence": "dropdown screenshot + one screenshot per visualization"
+ },
+ {
+ "clause": "bulk actions dispatch correctly by mode: per-record actions issue one dispatch per selected record; the aggregate def (showcase_recalc_selection, execution: 'aggregate') issues ONE request carrying every selected id in params._selectedIds",
+ "oracle": "network",
+ "verify": "count the captured POSTs against the selection size for each mode",
+ "evidence": "network trace of both runs"
+ },
+ {
+ "clause": "legacy string rowActions resolve against the object's declared actions — showcase_recalc_estimate is a live entry (not a dead menu item) and showcase_quick_view appears exactly once (no dead duplicate)",
+ "oracle": "dom",
+ "verify": "after screenshot, open a row menu on the legacy_row_actions view; click each entry and confirm it dispatches (objectui#2960 contract)",
+ "evidence": "menu screenshot + dispatch traces"
+ }
+ ],
+ "negative": [
+ "a saved view whose filter is refused (400 INVALID_FILTER) while the grid silently shows ALL rows unfiltered is a FAIL — the objectui#3431 regression shape; zero rows with a captured refusal is the honest symptom, unfiltered rows is the lie",
+ "userFilters leaking onto an OBJECT list view is a FAIL — ADR-0053 suppresses them there by design (filter elements belong to interface pages; objectui warns since #2220)",
+ "an export that returns the unfiltered table for a filtered view is a FAIL even though a file downloaded"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input",
+ "stale-console-bundle"
+ ],
+ "automated": {
+ "kind": "e2e",
+ "ref": "objectui: e2e/live/saved-view-filter.spec.ts, e2e/live/user-filters.spec.ts, e2e/live/inline-edit-polish-2572.spec.ts; packages/qa/dogfood/test/showcase-search.dogfood.test.ts"
+ },
+ "source": [
+ "examples/app-showcase/src/ui/views/task.view.ts (saved views, sort string form, inlineEdit, exportOptions, bulk/row actions, allowedVisualizations)",
+ "examples/app-showcase/src/ui/views/field-zoo.view.ts (conditionalFormatting, gated bulk actions)",
+ "packages/spec/src/ui/view.zod.ts (UserActionsConfigSchema, AppearanceConfigSchema/VisualizationTypeSchema)",
+ "packages/spec/src/data/pagination-conformance.ts (deterministic paging property)",
+ "examples/app-showcase/src/data/seed/index.ts (10 tasks / 2 in_progress; CJK rows)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — deep-test capability matrix for list surfaces, pinned to the objectui live specs where they exist",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.view-type-gallery",
+ "title": "View-type gallery: every ListViewSchema visualization renders seeded records",
+ "since": "v15",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_task views covering 8 types (examples/app-showcase/src/ui/views/task.view.ts: grid, board/kanban, cards/gallery, calendar, timeline, gantt, map, chart)",
+ "showcase_business_unit 'Organization Chart' for the 9th type, tree (examples/app-showcase/src/ui/views/business-unit.view.ts)",
+ "seeds sized to feed every view: 10 tasks across all 5 kanban columns with due/start/end/created dates and locations (src/data/seed/index.ts)"
+ ],
+ "knownGaps": [
+ "task 'cover' (gallery coverField) is declared but deliberately unseeded (#4891 / ADR-0104 — a managed sys_file cannot honestly be seeded); the gallery renders coverless cards by design — do not fail the gallery variant on missing covers, upload one to prove the cover path"
+ ]
+ },
+ "variants": [
+ "grid",
+ "kanban",
+ "gallery",
+ "calendar",
+ "timeline",
+ "gantt",
+ "map",
+ "chart",
+ "tree"
+ ],
+ "steps": [
+ "open /_console/apps/com.example.showcase/showcase_task and walk its named views: Grid, Board (Kanban), Cards (Gallery), Calendar, Activity Timeline, Schedule (Gantt), Work Locations (Map), Hours by Status (Chart)",
+ "on each: wait for render, screenshot FIRST, then read the DOM for the visualization's own structure (kanban columns, calendar cells, gantt bars, map markers, chart SVG)",
+ "on the kanban board, verify the groupByField columns match the 5 task statuses and every column is populated; check the summarizeField (estimate_hours) totals against an API aggregate",
+ "on calendar/timeline/gantt, spot-check 2 records' placement against their seeded due_date / created_at / start_date+end_date via API reads",
+ "on the chart view, confirm a real SVG is drawn from dataset showcase_task_metrics (not an empty canvas or a single bar from thin data)",
+ "open /_console/apps/com.example.showcase/showcase_business_unit and its Organization Chart view; verify the tree renders the seeded parent hierarchy",
+ "capture one screenshot per variant for the per-variant evidence set"
+ ],
+ "acceptance": [
+ {
+ "clause": "PER-VARIANT: each of the 9 ListViewSchema types renders its REAL visualization (not a grid fallback) with seeded records, with its own screenshot in the evidence set",
+ "oracle": "screenshot",
+ "verify": "one screenshot per variant showing the visualization's characteristic structure; a variant that silently fell back to grid is a FAIL for that variant",
+ "evidence": "9 screenshots keyed by variant"
+ },
+ {
+ "clause": "kanban groups by status with every seeded column populated, and the summarizeField per-column totals agree with a direct API aggregate of estimate_hours",
+ "oracle": "api",
+ "verify": "compare column headers/totals against GET /api/v1/data/showcase_task grouped client-side from the raw rows",
+ "evidence": "board screenshot + the aggregate check"
+ },
+ {
+ "clause": "temporal views place records by their true date fields: calendar by due_date, timeline by created_at, gantt bars spanning start_date→end_date with progressField rendered",
+ "oracle": "api",
+ "verify": "for 2 sampled tasks, the placement matches the API-read dates",
+ "evidence": "screenshots + the two API reads"
+ },
+ {
+ "clause": "map renders a marker per task with a location value; chart draws a non-empty SVG bound to the showcase_task_metrics dataset",
+ "oracle": "dom",
+ "verify": "after screenshots confirm render, count markers vs rows with location; assert svg element with plotted marks exists",
+ "evidence": "screenshots + DOM excerpts"
+ },
+ {
+ "clause": "tree renders the seeded business-unit hierarchy (parent references as nesting), expandable without error",
+ "oracle": "dom",
+ "verify": "after screenshot, the rendered nesting matches parent fields read via GET /api/v1/data/showcase_business_unit",
+ "evidence": "screenshot + API read"
+ },
+ {
+ "clause": "no variant throws a pageerror or renders an empty (the automated smoke covers the page-level render for these surfaces)",
+ "oracle": "test",
+ "verify": "pnpm -C examples/app-showcase test:smoke — the All Views / Task Board / Calendar / Gallery / Schedule / Timeline / Work Map SURFACES stay green",
+ "evidence": "test run output"
+ }
+ ],
+ "negative": [
+ "a visualization that renders as a plain grid (fallback) while its type claims kanban/calendar/gantt/etc. is a FAIL for that variant — 'it rendered something' is not the oracle",
+ "a chart that renders from a single datapoint proves little — note the weakness in evidence rather than ticking silently (single-datapoint trap)"
+ ],
+ "traps": [
+ "hydration-race",
+ "single-datapoint",
+ "wrong-panel"
+ ],
+ "automated": {
+ "kind": "e2e",
+ "ref": "examples/app-showcase/e2e/showcase-smoke.spec.ts (page-level render for the view surfaces)"
+ },
+ "source": [
+ "packages/spec/src/ui/view.zod.ts (ListViewSchema type enum: grid|kanban|gallery|calendar|timeline|gantt|map|chart|tree)",
+ "examples/app-showcase/src/coverage.ts (LIST_VIEW_TYPES — the 8 the task object demonstrates)",
+ "examples/app-showcase/src/ui/views/task.view.ts",
+ "examples/app-showcase/src/ui/views/business-unit.view.ts (tree)",
+ "examples/app-showcase/src/data/seed/index.ts (view-feeding seed shape)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — deep-test view-type gallery derived from the spec's own enum, 8 types on task + tree on business-unit",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.form-view-gallery",
+ "title": "Form-view gallery: every FormViewSchema layout type renders and submits",
+ "since": "v15",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_task formViews: edit (simple), tabbed, wizard, split, quick (drawer) — examples/app-showcase/src/ui/views/task.view.ts"
+ ],
+ "knownGaps": [
+ "the spec's 6th form type, 'modal', is not authored anywhere in the showcase (coverage.ts FORM_VIEW_TYPES deliberately lists 5) — record the modal variant blocked(fixture) rather than ticking or silently skipping it"
+ ]
+ },
+ "variants": [
+ "simple",
+ "tabbed",
+ "wizard",
+ "split",
+ "drawer",
+ "modal"
+ ],
+ "steps": [
+ "open a task record and its 'edit' (simple) form view; screenshot; verify the single 2-column section with the declared field order",
+ "open the tabbed form view; verify the three tabs (Overview / Schedule / Details) and that switching tabs preserves entered values",
+ "open the wizard form view; walk Basics → Assignment → Schedule with next/back; verify back preserves values and submit happens ONCE at the end",
+ "open the split form view; verify primary/secondary panes render their assigned sections side-by-side",
+ "trigger the quick (drawer) form; verify it renders as a side panel over the list with the 3 declared fields",
+ "on the simple form, set priority to 'urgent' and verify the notes field appears (FormField.visibleWhen CEL); set it back and verify notes hides",
+ "save an edit through each rendered variant; capture each save request; re-read via GET /api/v1/data/showcase_task/"
+ ],
+ "acceptance": [
+ {
+ "clause": "PER-VARIANT: each authored form type renders its own layout mechanics (sections / tabs / steps / panes / side panel), with a screenshot per variant; the unauthored 'modal' variant is recorded blocked(fixture), never ticked",
+ "oracle": "screenshot",
+ "verify": "one screenshot per variant showing the characteristic layout; wizard shows step chrome, split shows two panes, drawer overlays the list",
+ "evidence": "per-variant screenshots"
+ },
+ {
+ "clause": "the wizard enforces step order and issues exactly ONE save at the end (no per-step writes)",
+ "oracle": "network",
+ "verify": "network trace across the walk shows a single POST/PATCH at final submit",
+ "evidence": "the trace"
+ },
+ {
+ "clause": "view-level visibleWhen works live: notes renders only while priority == 'urgent', full-width via span 'full'",
+ "oracle": "dom",
+ "verify": "after screenshot, toggle priority and assert notes mounts/unmounts (same family as objectui e2e/live/field-conditional-rules.spec.ts)",
+ "evidence": "before/after screenshots"
+ },
+ {
+ "clause": "every save through every variant persists — API re-read shows the edited value and untouched fields unchanged",
+ "oracle": "api",
+ "verify": "GET /api/v1/data/showcase_task/ after each variant's save; diff against pre-save read",
+ "evidence": "the reads"
+ },
+ {
+ "clause": "tab/step navigation never loses entered-but-unsaved values (switching tabs or going back a wizard step preserves the draft)",
+ "oracle": "dom",
+ "verify": "enter a distinctive value, navigate away and back within the form, read the control value",
+ "evidence": "screenshots at each hop"
+ }
+ ],
+ "negative": [
+ "a wizard that lets Next past a required title with no named field error — or that silently writes per step — is a FAIL",
+ "counting the modal variant as passed because the other five rendered is a FAIL: it must be recorded blocked(fixture) with this item cited"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input"
+ ],
+ "source": [
+ "packages/spec/src/ui/view.zod.ts (FormViewSchema type enum: simple|tabbed|wizard|split|drawer|modal)",
+ "examples/app-showcase/src/coverage.ts (FORM_VIEW_TYPES — the 5 authored)",
+ "examples/app-showcase/src/ui/views/task.view.ts (formViews incl. visibleWhen on notes)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — deep-test form-view gallery derived from the spec enum; modal recorded as a standing fixture gap",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.conditional-rules-header",
+ "title": "Header-level conditional rules: visibleWhen / requiredWhen / readonlyWhen on invoice fields, enforced on BOTH ends",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "mixed",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_invoice conditional fields (examples/app-showcase/src/data/objects/invoice.object.ts): issued_on requiredWhen status in ['sent','paid']; tax_rate readonlyWhen status=='paid'; paid_on visibleWhen+requiredWhen status=='paid'; userActions edit disabledWhen / delete visibleWhen on paid",
+ "seeded invoices: INV-1002 (draft), INV-1001 (sent), INV-1003 (paid)"
+ ]
+ },
+ "steps": [
+ "open /_console/apps/com.example.showcase/showcase_invoice; open INV-1002 (draft) in its form",
+ "flip status draft → sent; observe issued_on gain a required marker; attempt save with issued_on empty; capture the block",
+ "set issued_on; save; re-read via GET /api/v1/data/showcase_invoice/",
+ "direct API negative: POST /api/v1/data/showcase_invoice with status 'sent' and no issued_on; capture the refusal",
+ "open INV-1003 (paid): verify paid_on is visible and required, tax_rate renders read-only",
+ "direct API: PATCH /api/v1/data/showcase_invoice/ with tax_rate: 99; then GET the row and compare tax_rate to its pre-PATCH value",
+ "on the invoice LIST, inspect INV-1003's row actions: Edit visible but disabled, Delete absent; compare a draft row's untouched menu",
+ "on INV-1002 (draft), verify paid_on is NOT rendered at all"
+ ],
+ "acceptance": [
+ {
+ "clause": "requiredWhen reacts live in the form: leaving Draft marks issued_on required and blocks submit with a named field error while empty",
+ "oracle": "dom",
+ "verify": "after screenshot, assert the required marker + the field-level error on blocked submit (pinned by objectui e2e/live/field-conditional-rules.spec.ts and required-when-submit.spec.ts)",
+ "evidence": "screenshots + the blocked submit"
+ },
+ {
+ "clause": "requiredWhen is enforced server-side: a direct POST with status 'sent' and no issued_on → VALIDATION_FAILED naming issued_on with code 'required' — the form is not the boundary",
+ "oracle": "api",
+ "verify": "the direct POST returns the 400-class envelope; invoice count for the test name stays 0",
+ "evidence": "refusal response + count check"
+ },
+ {
+ "clause": "readonlyWhen locks the client AND the server drops the change: tax_rate on a paid invoice renders read-only, and a direct PATCH to it is silently DISCARDED — the persisted value must be unchanged on re-read (stripReadonlyWhenFields semantics: keep, not reject)",
+ "oracle": "api",
+ "verify": "GET before, PATCH tax_rate 99, GET after — before == after; note: a 200 on the PATCH is expected, the ORACLE is the unchanged re-read",
+ "evidence": "the two reads + the PATCH"
+ },
+ {
+ "clause": "visibleWhen is honored: paid_on is absent from the draft form and present (and required) on the paid form",
+ "oracle": "dom",
+ "verify": "after screenshots of both forms, assert paid_on mounted only on paid",
+ "evidence": "both screenshots"
+ },
+ {
+ "clause": "per-record row-action gating follows the same CEL truth: on paid rows Edit is visible-but-disabled (disabledWhen) and Delete is hidden (visibleWhen); draft rows keep the full menu",
+ "oracle": "dom",
+ "verify": "after screenshot, read both rows' action menus (objectui#2614; pinned by objectui e2e/live/list-row-action-cel.spec.ts)",
+ "evidence": "both row-menu screenshots"
+ },
+ {
+ "clause": "the client and server evaluate ONE rule, not two: the same predicate that blocked the form blocks the API, and the field the client locked is the field the server strips",
+ "oracle": "api",
+ "verify": "cross-check clauses 1↔2 and 3: no case where the form blocks but the API accepts (or vice versa)",
+ "evidence": "the paired form + API results"
+ }
+ ],
+ "negative": [
+ "a direct API POST of a 'sent' invoice without issued_on that succeeds is a FAIL (client-only enforcement)",
+ "a PATCH to tax_rate on a paid invoice whose new value PERSISTS is a FAIL — silent acceptance of a locked field is the defect this rule exists to stop",
+ "a Delete affordance on a paid invoice row is a FAIL even if clicking it would error later"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input",
+ "stale-console-bundle"
+ ],
+ "automated": {
+ "kind": "e2e",
+ "ref": "objectui: e2e/live/field-conditional-rules.spec.ts, e2e/live/required-when-submit.spec.ts, e2e/live/list-row-action-cel.spec.ts"
+ },
+ "source": [
+ "examples/app-showcase/src/data/objects/invoice.object.ts (the B2 rules + userActions gating, with server-semantics comments)",
+ "packages/spec/src/data/field.zod.ts (requiredWhen/readonlyWhen/visibleWhen authoring surface)",
+ "packages/objectql/src/validation/rule-validator.ts (enforcement site)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — deep-test both-ends contract for header-level conditional rules on the seeded invoices",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.conditional-rules-grid",
+ "title": "Grid-level conditional rules: row-scoped requiredWhen and parent-scoped readonlyWhen in the inline line-item grid",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_invoice_line rules (examples/app-showcase/src/data/objects/invoice.object.ts): description requiredWhen record.quantity >= 100 (ROW-scoped); product/quantity/unit_price readonlyWhen parent.status == 'paid' (PARENT-scoped); receipt Field.file upload cell (objectui#2360)",
+ "a draft invoice with editable lines (INV-1002) and a paid one (INV-1003)"
+ ]
+ },
+ "steps": [
+ "open INV-1002 (draft) — the inline Line Items grid renders via inlineEdit: 'grid'",
+ "on one line, raise quantity to 120 with description empty; observe the description cell flag required ON THAT ROW; screenshot",
+ "confirm a second line with quantity 1 stays unflagged and editable (per-row evaluation)",
+ "attempt to save the batch with the flagged description still empty; capture the block; then fill it and save; capture the batch request",
+ "server-side negative: submit the same shape directly to the API (line with quantity 120, no description) and capture the refusal",
+ "flip the header status to paid and save; observe product/quantity/unit_price cells lock; flip back to sent/draft and observe them unlock (live parent-scope re-evaluation)",
+ "on a draft line, upload a file into the receipt cell; save the batch; re-read the line via API and confirm the stored managed-file reference"
+ ],
+ "acceptance": [
+ {
+ "clause": "row-scoped requiredWhen flags the cell per row: only the row crossing quantity >= 100 is marked, and the batch save is blocked with a named cell error while its description is empty",
+ "oracle": "dom",
+ "verify": "after screenshot, assert the required flag on row 1 and its absence on row 2; blocked save shows the error (pinned by objectui e2e/live/grid-conditional-rules.spec.ts)",
+ "evidence": "screenshots + blocked save"
+ },
+ {
+ "clause": "the same rule is enforced on the server write path: a direct batch write with the violating line → VALIDATION_FAILED naming description; no partial rows land",
+ "oracle": "api",
+ "verify": "the direct submit returns the refusal envelope; line count for the invoice is unchanged",
+ "evidence": "refusal + before/after line reads"
+ },
+ {
+ "clause": "parent-scoped readonlyWhen re-evaluates live against the header record: setting status to paid locks the three cells, reverting unlocks them — without a page reload",
+ "oracle": "dom",
+ "verify": "after screenshots at each state, assert cell editability (pinned by objectui e2e/live/grid-parent-rules.spec.ts)",
+ "evidence": "lock/unlock screenshots"
+ },
+ {
+ "clause": "the receipt upload cell stores a real managed file: the API re-read of the line carries the sys_file reference and the file is retrievable",
+ "oracle": "api",
+ "verify": "GET the line post-save; the receipt field holds the file id, not an inline blob (ADR-0104 stored form; pinned by objectui e2e/live/grid-file-upload.spec.ts)",
+ "evidence": "line read + fetched file"
+ },
+ {
+ "clause": "the saved batch persists: reloading the invoice shows the edited lines with their values, and untouched sibling lines byte-identical",
+ "oracle": "api",
+ "verify": "diff all lines before/after the save; only the edited line changed",
+ "evidence": "the before/after reads"
+ }
+ ],
+ "negative": [
+ "a batch save that succeeds with quantity 120 and empty description is a FAIL on both the client clause and the server clause",
+ "cells still editable on a paid invoice (or still locked after reverting) is a FAIL — the parent scope must re-evaluate live"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input"
+ ],
+ "automated": {
+ "kind": "e2e",
+ "ref": "objectui: e2e/live/grid-conditional-rules.spec.ts, e2e/live/grid-parent-rules.spec.ts, e2e/live/grid-file-upload.spec.ts"
+ },
+ "source": [
+ "examples/app-showcase/src/data/objects/invoice.object.ts (row/parent-scoped rules on the line object; ADR-0036 / #1581)",
+ "objectui: e2e/live/grid-conditional-rules.spec.ts, grid-parent-rules.spec.ts"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — deep-test grid conditional rules split from the header item (different scopes, different pins)",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.cascading-options",
+ "title": "Cascading and gated select options: dependsOn + per-option visibleWhen, client narrows / server rejects",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "mixed",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)",
+ "non-admin user (sign one up — sys_user rows cannot be seeded)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_cascade (examples/app-showcase/src/data/objects/cascading-select.object.ts): province dependsOn ['country'] with per-option visibleWhen (cn → zj/gd, us → ca/tx); tier option 'restricted' gated on 'admin' in current_user.positions; sharingModel public_read_write",
+ "showcase_invoice.contact dependsOn ['account'] — the dependent LOOKUP twin (contact picker scoped to the chosen account's people; seed gives Northwind 26 contacts, Contoso 2, Fabrikam 1)"
+ ]
+ },
+ "steps": [
+ "open a New Cascading Select form (/_console/apps/com.example.showcase/showcase_cascade or its nav entry)",
+ "verify province is gated (empty/disabled) until a country is chosen",
+ "pick country=cn; enumerate province options (expect exactly Zhejiang, Guangdong); switch to us; re-enumerate (California, Texas) and verify a previously chosen cn province CLEARED",
+ "as admin, enumerate tier options (Standard + Restricted); save a record with tier=restricted",
+ "direct API negative: POST /api/v1/data/showcase_cascade with country 'cn' and province 'ca'; capture the refusal; then POST province 'zj' and capture the success",
+ "as the non-admin user: enumerate tier options in the form (Restricted absent), then POST tier='restricted' directly and capture the refusal",
+ "on a New Invoice, verify the contact picker is empty-scoped before an account is chosen; pick Northwind and capture the picker's data request (scoped to Northwind's contacts); switch to Contoso and verify the candidate set changes"
+ ],
+ "acceptance": [
+ {
+ "clause": "dependsOn gates the dependent field until its driver has a value, and changing the driver re-filters the offered set live, clearing a now-invalid selection",
+ "oracle": "dom",
+ "verify": "after screenshots, enumerate the offered options at each country state (pinned by objectui e2e/live/cascading-options.spec.ts: 'province options re-filter live as country changes, and the stale value clears')",
+ "evidence": "option enumerations + screenshots"
+ },
+ {
+ "clause": "the server rejects an out-of-set submitted option with VALIDATION_FAILED and fields[] carrying {field: 'province', code: 'invalid_option'} — and accepts the in-set one",
+ "oracle": "api",
+ "verify": "the two direct POSTs from the steps; refusal envelope must name the field and code (pinned by the same spec's API half; server site: objectql evaluateOptionVisibility, objectui#2284)",
+ "evidence": "both responses"
+ },
+ {
+ "clause": "BOTH sides of the role gate: admin sees and can persist tier='restricted'; the non-admin neither sees it NOR can submit it — the direct non-admin POST is refused server-side",
+ "oracle": "api",
+ "verify": "admin create re-reads with tier='restricted'; non-admin POST returns the invalid_option refusal (current_user bound from the request on authenticated writes)",
+ "evidence": "admin re-read + non-admin refusal + both option enumerations"
+ },
+ {
+ "clause": "the dependent LOOKUP twin works: the invoice contact picker issues account-scoped candidate requests, and switching accounts changes the candidate set (26 for Northwind vs 2 for Contoso)",
+ "oracle": "network",
+ "verify": "captured picker requests carry the account scope; candidate counts match the seeded spread",
+ "evidence": "picker request traces + counts"
+ },
+ {
+ "clause": "a legal cascade selection persists: create with country=cn, province=zj re-reads verbatim over the API after reload",
+ "oracle": "api",
+ "verify": "GET the created row; both values present",
+ "evidence": "the re-read"
+ }
+ ],
+ "negative": [
+ "an out-of-set province accepted with 200 is a FAIL — client hiding is UX, the objectql rule-validator is the boundary",
+ "a non-admin's direct tier='restricted' POST accepted is a FAIL even though their picker hid the option (UI absence alone is a client courtesy — RUNNER rule 4)"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input",
+ "wrong-persona"
+ ],
+ "automated": {
+ "kind": "e2e",
+ "ref": "objectui: e2e/live/cascading-options.spec.ts"
+ },
+ "source": [
+ "examples/app-showcase/src/data/objects/cascading-select.object.ts (the B3 / #1583 fixture, with both-sides contract in its header comment)",
+ "examples/app-showcase/src/data/objects/invoice.object.ts (contact dependsOn account)",
+ "examples/app-showcase/src/data/seed/index.ts (contact spread per account)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — deep-test dynamic options: cascade, role gate, dependent lookup, server-side rejection",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.master-detail-atomic-save",
+ "title": "Master + line items save as one atomic batch, with server-side roll-up",
+ "since": "v16",
+ "status": "active",
+ "revision": 2,
+ "priority": "P1",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_invoice + showcase_invoice_line with inlineEdit: 'grid' on the master_detail field (examples/app-showcase/src/data/objects/invoice.object.ts) — every New/Edit Invoice form renders the editable Line Items grid",
+ "seeded products for the catalog lookup (SKUs incl. SERVICE-HR, WIDGET-A; picking one auto-fills description + unit_price)"
+ ]
+ },
+ "steps": [
+ "open New Invoice (/_console/apps/com.example.showcase/showcase_invoice → New); fill name os-qa-, account=Northwind, status=draft",
+ "add two lines in the inline grid: pick a product on each (observe description/unit_price auto-fill), set quantities; watch amount recompute read-only as qty × unit_price",
+ "Save ONCE; capture the network trace of the save",
+ "re-read master + lines via GET /api/v1/data/showcase_invoice?$filter=[[\"name\",\"=\",\"os-qa-\"]] and the lines by the returned invoice id; check invoice.total against the sum of line amounts",
+ "add a third line via the edit form and save; re-read invoice.total; delete that line; re-read total again",
+ "repeat the create with one child made deliberately invalid (product empty on a line); attempt save; capture the failure",
+ "verify no partial writes: filtered GET for the failed invoice name returns 0 rows and no orphan lines exist"
+ ],
+ "acceptance": [
+ {
+ "clause": "the happy-path save lands master + children in ONE atomic batch request (not N sequential writes), with the child ops referencing the parent",
+ "oracle": "network",
+ "verify": "the save issues a single batch call whose payload contains the parent and both line ops (pinned by objectui e2e/live/master-detail.spec.ts: 'Create submits the populated parent in one atomic batch' / 'includes the child op referencing the parent')",
+ "evidence": "the batch request payload"
+ },
+ {
+ "clause": "all rows are readable afterwards and correct: master + 2 lines re-read via API with the entered values; product pick auto-filled description/unit_price persisted",
+ "oracle": "api",
+ "verify": "field-by-field diff of the re-read against what was entered/auto-filled",
+ "evidence": "the reads"
+ },
+ {
+ "clause": "invoice.total is a SERVER-side roll-up: computed on the atomic create, recomputed when a line is added and again when it is deleted",
+ "oracle": "api",
+ "verify": "total == sum(line.amount) after each mutation (pinned by objectui e2e/live/summary-rollup.spec.ts: computed on atomic create; recomputes on child add/delete)",
+ "evidence": "the three total reads"
+ },
+ {
+ "clause": "a failing child aborts the WHOLE save — master not created, sibling lines not written, and the form surfaces a named error",
+ "oracle": "api",
+ "verify": "after the failed save, the filtered GET returns 0 invoices and no line rows reference a phantom parent",
+ "evidence": "the empty reads + the error screenshot"
+ },
+ {
+ "clause": "the relationship-derived subform/grid renders on the standard New form with NO hand-built page (derived from the master_detail declaration)",
+ "oracle": "dom",
+ "verify": "after screenshot, the Line Items grid is present on the stock New Invoice form (pinned by objectui e2e/live/form-view-subforms.spec.ts)",
+ "evidence": "form screenshot"
+ },
+ {
+ "clause": "the amount expression recomputes live client-side (read-only cell) and the computed value persists as the stored column the roll-up reads",
+ "oracle": "api",
+ "verify": "change qty, watch amount update without save; after save the API row carries qty × unit_price",
+ "evidence": "screenshot + line read"
+ }
+ ],
+ "negative": [
+ "the invalid-child case must not partially commit: ANY surviving master or sibling row after the failed save is a FAIL — check by API read, not by the grid",
+ "a save that issues one write per row (N requests) is a FAIL of the atomicity clause even when all rows land"
+ ],
+ "traps": [
+ "automation-input",
+ "hydration-race"
+ ],
+ "automated": {
+ "kind": "e2e",
+ "ref": "objectui: e2e/live/master-detail.spec.ts, e2e/live/summary-rollup.spec.ts, e2e/live/form-view-subforms.spec.ts"
+ },
+ "source": [
+ "#3358 §4",
+ "examples/app-showcase/src/data/objects/invoice.object.ts (inlineEdit grid, amount expression, total summary)",
+ "examples/app-showcase/src/data/seed/index.ts (product catalog rows)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial import from #3358",
+ "ref": "#3358"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.related-list-server-pagination",
+ "title": "Related lists derive from the relationship and page on the server — never fetch every child row — and are read-gated on both ends",
+ "since": "v16",
+ "status": "active",
+ "revision": 3,
+ "priority": "P1",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)",
+ "showcase_manager — reads BOTH showcase_account AND showcase_contact (readScope org) — the CHILD-read-entitled persona",
+ "showcase_contributor — reads showcase_account but NOT showcase_contact — the CHILD-read-DENIED persona"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "Northwind account with 26 seeded contacts (2 named + 24 'Prospect NN' rows authored precisely so the related list exceeds a page — examples/app-showcase/src/data/seed/index.ts, objectui#2711)",
+ "showcase_invoice.account declares relatedList: 'primary' with relatedListTitle 'Invoices' and relatedListColumns [name, status, total, issued_on] (examples/app-showcase/src/data/objects/invoice.object.ts)",
+ "the permission-zoo sets examples/app-showcase/src/security/permission-sets.ts: showcase_manager grants showcase_contact read (line 124), showcase_contributor omits it (lines 36-45) — the both-sides read-gate probe binds a user to each"
+ ],
+ "knownGaps": [
+ "the child-read-gate clause needs two signed-up users bound to showcase_manager and showcase_contributor — sys_user rows come from sign-up, not seeds — so on a single-user stock boot record that clause blocked(fixture); the paging clauses run on the seeded admin alone"
+ ]
+ },
+ "steps": [
+ "open the Northwind account detail page (/_console/apps/com.example.showcase/showcase_account → Northwind)",
+ "screenshot; enumerate the related-list tabs (expect relationship-derived lists incl. Contacts and the declared Invoices tab)",
+ "open the Contacts related list; capture the page-1 data request",
+ "click next-page; capture the page-2 request; note the pager text",
+ "diff page-1 and page-2 row ids for overlap",
+ "open the Invoices related tab; verify its title and columns match the declaration on the lookup field (name, status, total, issued_on)",
+ "capture every data request issued by the detail page and check each child query for a $top bound",
+ "read-gate both-sides: sign in as showcase_contributor (reads showcase_account, NOT showcase_contact); open the Northwind account detail; screenshot and confirm the Contacts related section/tab is ABSENT; then forge GET /api/v1/data/showcase_contact?$filter=[[\"account\",\"=\",\"\"]] as that persona and capture the 403",
+ "repeat as showcase_manager (has showcase_contact read): confirm the Contacts section renders AND the same child query answers 200 with rows"
+ ],
+ "acceptance": [
+ {
+ "clause": "paging issues server-side $top/$skip requests scoped by the parent filter; the full child set is never fetched",
+ "oracle": "network",
+ "verify": "page 1 = $top=N + parent filter; page 2 = $top=N&$skip=N; NO child-list request without $top anywhere on the page",
+ "evidence": "the captured request URLs"
+ },
+ {
+ "clause": "the pager reflects the server total (26 Northwind contacts), and pages partition the set — no row repeated or skipped across pages",
+ "oracle": "network",
+ "verify": "pager reads 'page 2 of M' consistent with total 26; id sets of page 1 and 2 are disjoint",
+ "evidence": "pager screenshot + the id diff"
+ },
+ {
+ "clause": "related lists are DERIVED from the relationship declaration with no hand-built page: the Account detail auto-renders them (Projects is the pinned example)",
+ "oracle": "dom",
+ "verify": "after screenshot, the related tabs exist on the stock detail page (pinned by objectui e2e/live/detail-related-list.spec.ts)",
+ "evidence": "detail screenshot"
+ },
+ {
+ "clause": "the declared relatedList config is honored: the Invoices tab carries the authored title and exactly the authored columns",
+ "oracle": "dom",
+ "verify": "after screenshot, read the tab label and column headers; compare to relatedListTitle/relatedListColumns on the lookup field",
+ "evidence": "tab screenshot + declaration excerpt"
+ },
+ {
+ "clause": "row navigation works from the related list: clicking a child row opens its record, and the child re-reads via its own API id",
+ "oracle": "api",
+ "verify": "GET /api/v1/data/showcase_contact/ matches the row navigated to",
+ "evidence": "navigation screenshot + the read"
+ },
+ {
+ "clause": "related lists are READ-gated on BOTH ends: a persona WITHOUT read on the child object (showcase_contributor lacks showcase_contact read) sees NO Contacts section on the account detail (UI courtesy — deriveRelatedLists drops children the user cannot read, objectui#2359) AND a direct child query is refused server-side (403); a persona WITH child read (showcase_manager) sees the section AND the query 200s",
+ "oracle": "api",
+ "verify": "as showcase_contributor: screenshot confirms the Contacts tab is absent, and the forged GET /api/v1/data/showcase_contact?$filter=[[\"account\",\"=\",\"\"]] returns 403; as showcase_manager: the tab renders and the identical query returns 200 with rows — the server is the authority (ADR-0057 D10, RUNNER rule 4), the UI drop is courtesy",
+ "evidence": "both personas' detail screenshots + the 403 and the 200 child queries"
+ }
+ ],
+ "negative": [
+ "any child-list request WITHOUT a $top bound is a FAIL even when the rendered page looks right — the unpaged fetch is the defect",
+ "a pager total that disagrees with a direct filtered API count (GET /api/v1/data/showcase_contact?$filter=[[\"account\",\"=\",\"\"]]) is a FAIL",
+ "a Contacts section rendering for showcase_contributor (an empty grid + a New button that 403s on save) is the objectui#2359 regression shape — FAIL; equally, the child query returning rows to a persona the object read gate denies is a server-side FAIL (UI absence alone never proves the server refuses)"
+ ],
+ "traps": [
+ "hydration-race"
+ ],
+ "automated": {
+ "kind": "e2e",
+ "ref": "objectui: e2e/live/detail-related-list.spec.ts"
+ },
+ "source": [
+ "#3358 §4 (evidence run captured exactly this trace)",
+ "examples/app-showcase/src/data/seed/index.ts (the 26-contact Northwind fixture, authored for objectui#2711)",
+ "examples/app-showcase/src/data/objects/invoice.object.ts (relatedList declaration)",
+ "objectui: packages/app-shell/src/views/RecordDetailView.tsx (deriveRelatedLists canRead filter — object-level READ gate, objectui#2359)",
+ "examples/app-showcase/src/security/permission-sets.ts (showcase_manager child read vs showcase_contributor's omission); PENDING-GAPS §E3 / objectui#2565"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial import from the #3358 evidence run",
+ "ref": "#3358"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 3,
+ "date": "2026-08-08",
+ "change": "added the §E3 read-gating both-sides clause (child-object read gate: related section absent in the UI AND child query 403s server-side) with the permission-zoo personas (manager reads child, contributor does not); objectui#2359/#2565",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.action-param-widgets",
+ "title": "Action params render their real widgets, and the param contract is enforced at dispatch",
+ "since": "v16",
+ "status": "active",
+ "revision": 2,
+ "priority": "P1",
+ "surface": "mixed",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_action_param_gallery on showcase_field_zoo (examples/app-showcase/src/ui/actions/index.ts): p_text (text, required), p_richtext, p_priority (select), p_date, p_account (lookup→showcase_account), p_assignee (lookup→sys_user), p_color, p_reference (autonumber), p_cover (image, accept image/*, maxSize 5MB), p_attachments (file, multiple, accept pdf+image, maxSize 10MB)"
+ ]
+ },
+ "variants": [
+ "p_text (text)",
+ "p_richtext (richtext)",
+ "p_priority (select)",
+ "p_date (date)",
+ "p_account (lookup → showcase_account)",
+ "p_assignee (lookup → sys_user)",
+ "p_color (color)",
+ "p_reference (autonumber)",
+ "p_cover (image)",
+ "p_attachments (file, multiple)"
+ ],
+ "steps": [
+ "open Field Zoo (/_console/apps/com.example.showcase/showcase_field_zoo); run the gallery action from the Specimen — Full row menu",
+ "wait for the dialog; screenshot FIRST",
+ "enumerate each param's rendered control from the dialog DOM and build the variant→control table",
+ "read accept/multiple off the two upload inputs as real DOM attributes",
+ "fill a conformant bag (p_text set, valid p_priority) and confirm; capture the dispatch POST /api/v1/actions/showcase_field_zoo/showcase_action_param_gallery",
+ "re-open and confirm with p_text EMPTY; capture the outcome",
+ "direct API negative: POST the dispatch route with a malformed bag (e.g. p_priority out-of-set) and capture the refusal"
+ ],
+ "acceptance": [
+ {
+ "clause": "PER-VARIANT: each declared param renders its real widget (date→date input, color→color input, richtext→editor, select→picker, lookup→record picker, file/image→input[type=file], autonumber→read-only server-assigned) — recorded per variant in the table",
+ "oracle": "dom",
+ "verify": "after the screenshot confirms render, match each control against the declared param type; every variant row carries its own observed control",
+ "evidence": "screenshot + the 10-row param→control table"
+ },
+ {
+ "clause": "accept and multiple are REAL DOM attributes on the upload inputs, matching the declaration (p_cover: image/*; p_attachments: multiple + application/pdf,image/*)",
+ "oracle": "dom",
+ "verify": "read the attributes off both inputs post-render",
+ "evidence": "DOM excerpt"
+ },
+ {
+ "clause": "the declared param contract is enforced at dispatch BEFORE the body runs (strict since 17.0, #3438): a malformed bag is rejected 400-class; the conformant bag passes and the body echoes the received keys",
+ "oracle": "api",
+ "verify": "the two direct POSTs against /api/v1/actions/showcase_field_zoo/showcase_action_param_gallery (pinned by packages/qa/dogfood/test/action-params-contract.dogfood.test.ts, ADR-0104 D2)",
+ "evidence": "both responses"
+ },
+ {
+ "clause": "required p_text is enforced on both ends: the dialog blocks confirm (or errors) with it empty, and a direct dispatch without it is refused server-side",
+ "oracle": "api",
+ "verify": "UI attempt + direct POST both refuse with a named error; no action execution recorded",
+ "evidence": "UI screenshot + API refusal"
+ }
+ ],
+ "negative": [
+ "maxSize is enforced in JS, not as a DOM attribute — do NOT claim it from attribute absence; it needs an oversized-upload attempt (covered by records-forms.upload-guard-blocks-confirm)",
+ "a malformed param bag that reaches the action body (echo shows the bad key accepted) is a FAIL — the dispatch gate, not the widget, is the boundary"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input"
+ ],
+ "automated": {
+ "kind": "api",
+ "ref": "packages/qa/dogfood/test/action-params-contract.dogfood.test.ts; objectui: e2e/live/action-modal.spec.ts"
+ },
+ "source": [
+ "#3358 §4 (evidence table)",
+ "#3393",
+ "examples/app-showcase/src/ui/actions/index.ts (the gallery action's declared params)",
+ "ADR-0059 (param-dialog widgets)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial import from #3358, keeping its maxSize caveat as a negative-side note",
+ "ref": "#3358"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.upload-guard-blocks-confirm",
+ "title": "Confirm stays disabled while a file param is still uploading; maxSize is enforced by a real oversized attempt",
+ "since": "v16",
+ "status": "active",
+ "revision": 2,
+ "priority": "P2",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_action_param_gallery's p_attachments (maxSize 10MB) and p_cover (maxSize 5MB) — the ADR-0059 upload-guard fixtures (examples/app-showcase/src/ui/actions/index.ts)"
+ ]
+ },
+ "steps": [
+ "open the gallery action dialog from the Field Zoo row menu (screenshot first)",
+ "prepare a real file large enough to catch mid-flight (~8MB, under the 10MB p_attachments limit); start the upload into p_attachments",
+ "while the upload is in flight, attempt to click Confirm; capture the network trace across the attempt",
+ "wait for upload completion; confirm; capture the dispatch request",
+ "re-open the dialog; attempt an OVERSIZED file (>10MB) into p_attachments and (>5MB) into p_cover; capture the client's response",
+ "verify no upload request was issued for the oversized attempts"
+ ],
+ "acceptance": [
+ {
+ "clause": "the Confirm control is actually disabled mid-upload and enables on completion — proven by catching a real upload in flight, not by reading the helper label",
+ "oracle": "dom",
+ "verify": "during the in-flight window the button carries disabled state AND clicking issues no dispatch request; after completion the confirm succeeds",
+ "evidence": "mid-flight screenshot + network trace showing no premature submit"
+ },
+ {
+ "clause": "the completed dispatch carries the uploaded file reference and the action executes",
+ "oracle": "network",
+ "verify": "the POST /api/v1/actions/showcase_field_zoo/showcase_action_param_gallery after completion returns success with the file param populated",
+ "evidence": "the dispatch trace"
+ },
+ {
+ "clause": "maxSize is enforced by attempt: an oversized file is rejected with a named client error BEFORE any upload request is issued (both the 10MB and the 5MB limits)",
+ "oracle": "network",
+ "verify": "the oversized attempts produce a visible error and zero upload requests in the trace",
+ "evidence": "error screenshots + the empty trace window"
+ },
+ {
+ "clause": "a rejected oversized file leaves the dialog usable: a subsequent valid file uploads and confirms normally",
+ "oracle": "dom",
+ "verify": "after the rejection, repeat a valid upload and confirm",
+ "evidence": "final success screenshot"
+ }
+ ],
+ "negative": [
+ "a confirm click mid-upload that ISSUES the dispatch is a FAIL even if the server would cope",
+ "an oversized file that starts uploading (any upload request observed) is a FAIL — the guard must reject before the wire"
+ ],
+ "traps": [
+ "automation-input",
+ "hydration-race"
+ ],
+ "source": [
+ "#3358 §4 ('leaving it unticked on the strength of a label')",
+ "ADR-0059",
+ "examples/app-showcase/src/ui/actions/index.ts (maxSize declarations)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — codifies the #3358 refusal to tick from a label into the oracle itself",
+ "ref": "#3358"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.import-wizard-encoding-and-hints",
+ "title": "CSV import: GBK decode, required-field hint, legacy-fallback notice",
+ "since": "v16",
+ "status": "active",
+ "revision": 2,
+ "priority": "P2",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "an importable showcase object (e.g. showcase_contact) and a GBK-encoded CSV fixture; the pinned unit suites carry their own byte fixtures"
+ ]
+ },
+ "steps": [
+ "run the pinned unit suites (they feed real GBK bytes and assert the exact decode, the disabled-Next hint, and the visible degrade notice): pnpm --filter @object-ui/plugin-grid exec vitest run src/importParsers.test.ts src/__tests__/importMissingRequiredHint.test.tsx src/__tests__/importLegacyReferenceGuard.test.tsx",
+ "capture the suite output as the primary evidence (the native file-picker hand-off is deliberately not browser-automated)",
+ "optionally spot-check visually: open the import wizard on showcase_contact, load a GBK CSV past the native picker, and screenshot the decoded preview",
+ "in the spot-check, map columns while leaving a required field (name) unmapped; observe the Next control and its hint",
+ "verify the wizard's outcome server-side after a completed import: filtered GET /api/v1/data/showcase_contact for an imported marker row"
+ ],
+ "acceptance": [
+ {
+ "clause": "GBK bytes decode to the exact expected strings (no mojibake) — pinned",
+ "oracle": "test",
+ "verify": "pnpm --filter @object-ui/plugin-grid exec vitest run src/importParsers.test.ts",
+ "evidence": "test run output"
+ },
+ {
+ "clause": "an unmapped required field disables Next AND shows the named required-field hint — pinned",
+ "oracle": "test",
+ "verify": "pnpm --filter @object-ui/plugin-grid exec vitest run src/__tests__/importMissingRequiredHint.test.tsx",
+ "evidence": "test run output"
+ },
+ {
+ "clause": "the legacy-reference fallback path shows a visible degrade notice instead of silently degrading — pinned",
+ "oracle": "test",
+ "verify": "pnpm --filter @object-ui/plugin-grid exec vitest run src/__tests__/importLegacyReferenceGuard.test.tsx",
+ "evidence": "test run output"
+ },
+ {
+ "clause": "a completed import lands rows the API can read back (the wizard's end state is server rows, not a success toast)",
+ "oracle": "api",
+ "verify": "filtered GET for an imported marker row returns it with decoded values intact",
+ "evidence": "the read"
+ }
+ ],
+ "negative": [
+ "a GBK file whose preview renders mojibake while the tests pass means the WIRED wizard regressed against the pinned parser — a FAIL, file against the wizard wiring",
+ "an import that reports success while the filtered API read finds no rows is a FAIL (silent drop)"
+ ],
+ "automated": {
+ "kind": "unit",
+ "ref": "objectui: packages/plugin-grid/src/importParsers.test.ts (+ importMissingRequiredHint, importLegacyReferenceGuard)"
+ },
+ "traps": [
+ "automation-input",
+ "stale-console-bundle"
+ ],
+ "source": [
+ "#3358 §4 (ticked on test evidence — the native file picker hand-off is deliberately not automated)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial import from #3358's test-evidence resolution",
+ "ref": "#3358"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-07",
+ "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.action-location-matrix",
+ "title": "Action buttons surface at exactly their declared locations — list toolbar, list row, record header/more/related/section, global nav — and each dispatches for real",
+ "since": "v15",
+ "status": "active",
+ "revision": 2,
+ "priority": "P1",
+ "surface": "browser",
+ "personas": [
+ "admin"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the per-location action fleet on showcase_task (examples/app-showcase/src/ui/actions/index.ts): showcase_bulk_reassign (list_item+list_toolbar, flow), showcase_quick_view (list_item, modal), showcase_mark_done (list_item+record_header+record_section, script, visible '!record.done'), showcase_log_time (record_header+record_related+record_section, form), showcase_open_docs (record_more, url), showcase_recalc_selection (record_more, api — deliberately kept OFF the toolbar, objectui#3142), showcase_new_task (global_nav, modal)",
+ "seeded tasks in both done and not-done states for the CEL visibility both-sides check"
+ ]
+ },
+ "variants": [
+ "list_toolbar — showcase_bulk_reassign on the task list header",
+ "list_item — showcase_quick_view / showcase_mark_done in the row menu",
+ "record_header — showcase_mark_done / showcase_log_time in the detail title bar",
+ "record_more — showcase_open_docs / showcase_recalc_selection under the ⋯ overflow",
+ "record_related — showcase_log_time on the related-list section",
+ "record_section — showcase_mark_done / showcase_log_time in the Task Detail quick-actions bar (record:quick_actions resolves through the location filter)",
+ "global_nav — showcase_new_task in the command palette / global nav",
+ "empty-locations semantics probe — a locations-less action lands on EVERY location including the toolbar (objectui action-bar.tsx documented behavior; the reason recalc_selection must declare record_more)"
+ ],
+ "steps": [
+ "boot the showcase isolated; sign in as admin; open the showcase_task list view",
+ "for each location variant, navigate to its surface (list header / row menu / a not-done task's detail title bar / its ⋯ menu / a related-list section / the Task Detail quick-actions bar / the global command palette), screenshot AFTER render settles, then read the rendered action buttons from the DOM",
+ "record for every fixture action WHERE it rendered — building the full placement matrix (rendered locations vs declared locations)",
+ "dispatch one action per location with a ref-targeted click: bulk_reassign (screen-flow wizard opens), quick_view (modal opens), mark_done (script executes), log_time (form dialog opens on showcase_task.edit), open_docs (url navigation), recalc_selection from the ⋯ menu (api POST), new_task from the palette; capture each network trace",
+ "verify the state-changing dispatches server-side: mark_done flips the task's done flag (API re-read), recalc_selection's per-record branch recomputes the estimate",
+ "CEL visibility both sides: locate a done task and a not-done task; read the row menu and record header of each for showcase_mark_done",
+ "empty-locations probe: in a scratch/writable package author a copy of an api action with NO locations key; reload and record every surface it appears on (including the toolbar), then delete the probe"
+ ],
+ "acceptance": [
+ {
+ "clause": "PER-VARIANT: every location renders at least one action declared for it, in the correct UI slot — the full placement matrix (rendered vs declared) has zero missing placements",
+ "oracle": "dom",
+ "verify": "after each surface's screenshot confirms render, the DOM read lists the expected action names in that slot; matrix compiled per variant",
+ "evidence": "per-location screenshots + the placement matrix"
+ },
+ {
+ "clause": "placement is EXCLUSIVE, not additive: the matrix has zero extra placements — record_more-only actions (open_docs, recalc_selection) never render in record_header or list_toolbar; global_nav-only new_task never renders on rows; the engine location-filters even explicitly-named actions (the record:quick_actions bar note in the fixture source)",
+ "oracle": "dom",
+ "verify": "the placement matrix's extra-placement cells are all empty, checked against every captured surface",
+ "evidence": "the same matrix, extra-placement columns"
+ },
+ {
+ "clause": "each location's sampled action DISPATCHES for real — flow wizard opens and resumes, modal opens, script executes, form opens the declared edit form view, url navigates, api POSTs — and state-changing ones round-trip server-side (mark_done flips done; recalc updates the estimate)",
+ "oracle": "network",
+ "verify": "one captured dispatch per location + API re-reads for the two state changes",
+ "evidence": "the seven traces + the two re-reads"
+ },
+ {
+ "clause": "row-level CEL visibility gates per record, both sides: showcase_mark_done ('visible: !record.done') renders on the not-done task's row/header and is ABSENT on the done task's — and the evaluation is fail-closed (a throwing expression hides, never shows)",
+ "oracle": "dom",
+ "verify": "side-by-side DOM reads of the two rows and the two record headers",
+ "evidence": "the four reads + screenshots"
+ },
+ {
+ "clause": "empty/missing locations means EVERY location — the probe action appears on all surfaced slots including the list toolbar (the objectui#3142 semantics that forces recalc_selection to declare record_more, because a toolbar dispatch has no selection and the endpoint rejects it)",
+ "oracle": "dom",
+ "verify": "the locations-less probe's placement list covers all applicable surfaces; recalc_selection itself stays OFF the toolbar",
+ "evidence": "probe placement list + toolbar DOM read"
+ }
+ ],
+ "negative": [
+ "any action rendering at a location it did not declare (and did not inherit via the empty-locations rule) is a FAIL — placement is a contract, not a hint",
+ "showcase_recalc_selection appearing on the list toolbar is the objectui#3142 regression shape — FAIL even though clicking it would merely error",
+ "a dispatch that opens the wrong target (e.g. log_time opening a list view instead of the showcase_task.edit form — the #2554 build-gate class) is a FAIL of the dispatch clause, not a cosmetic note"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input",
+ "stale-console-bundle"
+ ],
+ "automated": {
+ "kind": "e2e",
+ "ref": "objectui: e2e/live/list-row-action-cel.spec.ts (row-level CEL visibility) + e2e/live/action-modal.spec.ts (dialog dispatch)"
+ },
+ "source": [
+ "packages/spec/src/ui/action.zod.ts:397 (ACTION_LOCATIONS — the canonical 7-value enum, single source of truth)",
+ "examples/app-showcase/src/ui/actions/index.ts (per-location fixture fleet + the record:quick_actions filter note + the objectui#3142 empty-locations commentary)",
+ "objectui: packages/.../action-bar.tsx (missing/empty locations → every location)",
+ "cross-ref: bulk dispatch-count semantics live in records-forms.list-view-capabilities (bulk-actions variant); param dialogs in records-forms.action-param-widgets"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — closes the button-placement gap: list toolbar / list row / bulk bar / detail-page buttons were covered piecemeal but never as the ACTION_LOCATIONS matrix",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ },
+ {
+ "revision": 2,
+ "date": "2026-08-08",
+ "change": "pinned enumSource for the variants-freshness ratchet — spec enum drift is caught by the manual check on this item directly",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ],
+ "enumSource": {
+ "file": "packages/spec/src/ui/action.zod.ts",
+ "export": "ACTION_LOCATIONS",
+ "expect": 7
+ }
+ },
+ {
+ "id": "records-forms.validation-rule-type-matrix",
+ "title": "All six validation-rule types enforce on the write path with their exact per-type error codes",
+ "since": "v15",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "api",
+ "personas": [
+ "admin"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the seeded per-type rules: showcase_account account_lifecycle (state_machine), tax_id_format + billing_email_format (format), support_config_shape (json_schema), churn_reason_consistency (conditional); showcase_project end_after_start (cross_field), spent_within_budget (script), project_status_flow (state_machine)"
+ ]
+ },
+ "variants": [
+ "state_machine — create with non-initial status → invalid_initial_state; illegal transition → invalid_transition",
+ "format — bad tax_id / billing_email → invalid_format",
+ "json_schema — off-schema support_config → json_schema_violation; non-JSON string → invalid_json",
+ "cross_field — project end_date < start_date → rule_violation naming end_date",
+ "script — spent > budget → rule_violation",
+ "conditional — status 'churned' without churn_reason → the wrapped rule fires; with churn_reason present it does not"
+ ],
+ "steps": [
+ "boot showcase isolated; sign in as admin",
+ "for each variant: POST/PATCH the violating payload over /api/v1/data/; capture status + error body",
+ "for each variant: send the happy-path twin (same shape, satisfying values) and capture success",
+ "after every rejection, re-read the row set to confirm nothing persisted",
+ "for state_machine additionally: create with a legal initial state, walk one legal transition, then attempt the illegal one"
+ ],
+ "acceptance": [
+ {
+ "clause": "each violating write answers 400 VALIDATION_FAILED with the per-type field code exactly as ledgered: invalid_initial_state / invalid_transition / invalid_format / json_schema_violation / invalid_json / rule_violation — six variants, six distinct proofs, none inferred from a sibling",
+ "oracle": "api",
+ "verify": "per-variant response status + fields[].code against the rule-validator dispatch (packages/objectql/src/validation/rule-validator.ts evaluateRule)",
+ "evidence": "the six response bodies keyed by variant"
+ },
+ {
+ "clause": "the error targets the declared field (cross_field targets fields[0] per the spec's own comment; format targets the formatted field) — actionable, not a bare object-level failure",
+ "oracle": "api",
+ "verify": "fields[].field matches the rule's declared target per variant",
+ "evidence": "the field targeting in each body"
+ },
+ {
+ "clause": "no rejected write persists — row counts and byte-identical rows across each rejection",
+ "oracle": "api",
+ "verify": "post-rejection re-reads",
+ "evidence": "the re-reads"
+ },
+ {
+ "clause": "every happy-path twin lands 2xx — the rules gate violations, they do not block legitimate writes",
+ "oracle": "api",
+ "verify": "the six success responses + persisted rows",
+ "evidence": "the twins"
+ }
+ ],
+ "negative": [
+ "an unevaluable CEL expression must fail CLOSED (rule_violation), never fail-open silently accepting the write — the rule-validator's documented posture"
+ ],
+ "traps": [
+ "wrong-persona"
+ ],
+ "source": [
+ "packages/spec/src/data/validation.zod.ts (ValidationRuleSchema, 6 discriminated variants)",
+ "packages/objectql/src/validation/rule-validator.ts (evaluateRule switch + per-type codes)",
+ "examples/app-showcase/src/data/objects/{account,project,task}.object.ts (the seeded rules)",
+ "#1475 (declared ≠ enforced history: 9 declared → 6 declared+enforced)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — gap found by the capability sweep: 6 rule types all seeded, none individually asserted anywhere",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.gantt-interactions",
+ "title": "Gantt is interactive, not a picture: drag persists, locked tasks survive auto-schedule, host veto restores",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "browser",
+ "personas": [
+ "admin"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the task gantt view (task.view.ts 'Schedule (Gantt)': startDateField/endDateField/titleField/progressField)"
+ ],
+ "knownGaps": [
+ "the stock view declares NO dependenciesField / lockField / parentField, so auto-schedule + locked-task + subtree-drag surfaces are unreachable on stock fixtures — needs the view extended (objectui carries unit pins for those behaviors: GanttView.autoscheduledlg / summaryedit / deptypes tests)"
+ ]
+ },
+ "variants": [
+ "bar drag → server PATCH (runnable on stock fixture)",
+ "auto-schedule confirms first: 'Shift N task(s)… (M locked skipped)' and writes NOTHING before confirm (fixture-gated)",
+ "locked task refuses drag and survives auto-schedule byte-identical (fixture-gated)",
+ "host onBeforeTaskUpdate veto restores the bar with no write (fixture-gated)"
+ ],
+ "steps": [
+ "open the task Schedule (Gantt) view; wait for bars to render (screenshot first)",
+ "drag one task bar to new dates; capture the PATCH and re-read the record over the API",
+ "reload and confirm the bar re-renders from the persisted dates",
+ "on an extended fixture (per knownGaps): configure dependencies + a locked row; run toolbar auto-schedule; capture the confirm dialog, cancel once (verify zero writes), run again and apply",
+ "trigger a veto path (write rejected server-side) and confirm the bar snaps back"
+ ],
+ "acceptance": [
+ {
+ "clause": "a bar drag issues the record PATCH and the API re-read shows the new start/end — pixels are not the oracle, the row is",
+ "oracle": "api",
+ "verify": "captured PATCH + re-read; reload re-renders from server values",
+ "evidence": "trace + re-read + post-reload screenshot"
+ },
+ {
+ "clause": "auto-schedule is confirm-first: cancel writes nothing (row set byte-identical), apply shifts exactly the unlocked affected set and reports skipped locked count",
+ "oracle": "api",
+ "verify": "row-set diff after cancel (empty) and after apply (only unlocked tasks moved); dialog text carries N and M",
+ "evidence": "diffs + dialog screenshot"
+ },
+ {
+ "clause": "a locked task's dates survive both direct drag attempts and auto-schedule unchanged",
+ "oracle": "api",
+ "verify": "before/after reads on the locked row",
+ "evidence": "the reads"
+ },
+ {
+ "clause": "a vetoed update leaves no write and restores the visual state",
+ "oracle": "api",
+ "verify": "no PATCH lands (or the failed one has no effect) and the re-read is unchanged",
+ "evidence": "trace + re-read"
+ }
+ ],
+ "negative": [
+ "a drag that repaints the bar but lands no PATCH (or a PATCH that 4xxs while the bar keeps the new position) is a FAIL — the #3358 §8 rows exist precisely because gantt can lie visually"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input"
+ ],
+ "automated": {
+ "kind": "unit",
+ "ref": "objectui: packages/plugin-gantt/src (GanttView.autoscheduledlg.test.tsx, GanttView.summaryedit.test.tsx, GanttView.deptypes.test.tsx, scheduling.selfextent.test.ts)"
+ },
+ "source": [
+ "#3358 §8 (the three never-imported gantt rows)",
+ "objectui: packages/plugin-gantt/src/GanttView.tsx + scheduling.ts (RescheduleResult.skippedLocked)",
+ "packages/spec/src/ui/view.zod.ts (GanttConfigSchema)",
+ "examples/app-showcase/src/ui/views/task.view.ts (stock fixture limits)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — #3358 §8 interaction rows were never imported; render-only coverage existed in view-type-gallery",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.kanban-drag-persistence",
+ "title": "Kanban card drag across columns persists the group-field change server-side",
+ "since": "v15",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "browser",
+ "personas": [
+ "admin"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the task kanban view (grouped by status) with cards in at least two columns"
+ ]
+ },
+ "steps": [
+ "open the task kanban; screenshot after render",
+ "drag one card from column A to column B; capture the update request",
+ "re-read the record over the API; reload the view and locate the card",
+ "attempt a drag that the server rejects (e.g. an illegal state_machine transition per task_status_flow) and observe the recovery"
+ ],
+ "acceptance": [
+ {
+ "clause": "the drop issues the record update carrying the new group-field value and the API re-read confirms it",
+ "oracle": "api",
+ "verify": "captured PATCH + re-read shows status = column B's value",
+ "evidence": "trace + re-read"
+ },
+ {
+ "clause": "the move survives a reload — the card renders in column B from server state",
+ "oracle": "screenshot",
+ "verify": "post-reload screenshot",
+ "evidence": "screenshot"
+ },
+ {
+ "clause": "a server-rejected move (illegal FSM transition → 400 invalid_transition) returns the card to its source column with a visible error — not a silently stuck optimistic state",
+ "oracle": "network",
+ "verify": "the 400 + the card's post-rejection column + the surfaced error",
+ "evidence": "trace + screenshot"
+ }
+ ],
+ "negative": [
+ "an optimistic move that sticks visually after a failed write is the FAIL this item exists for — cross-checks records-forms.validation-rule-type-matrix's state_machine variant from the UI side"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input"
+ ],
+ "source": [
+ "objectui: packages/plugin-kanban/src/KanbanImpl.tsx (onDragEnd)",
+ "examples/app-showcase/src/data/objects/task.object.ts (task_status_flow state_machine)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — kanban was render-only in view-type-gallery; the drag interaction chain had no coverage",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.form-dirty-guard",
+ "title": "Dirty forms guard navigation: discard prompts, save proceeds, nothing is lost silently",
+ "since": "v15",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "browser",
+ "personas": [
+ "admin"
+ ],
+ "steps": [
+ "open a record edit form (modal or drawer); change one field WITHOUT saving",
+ "attempt to close the dialog / navigate away; capture the guard prompt",
+ "choose stay/cancel — confirm the edit is still in the form",
+ "choose discard — confirm the record is unchanged server-side and the form state is dropped",
+ "repeat the edit and save normally — confirm persistence",
+ "repeat with NO edits: closing must NOT prompt (clean forms exit freely)"
+ ],
+ "acceptance": [
+ {
+ "clause": "a dirty form intercepts close/navigation with the discard guard; a clean form closes without friction — both sides",
+ "oracle": "screenshot",
+ "verify": "prompt appears exactly when dirty",
+ "evidence": "both screenshots"
+ },
+ {
+ "clause": "discard leaves the server row byte-identical; save persists — the guard's two exits both behave",
+ "oracle": "api",
+ "verify": "re-reads after each exit",
+ "evidence": "the reads"
+ },
+ {
+ "clause": "the guard's behavior is pinned by the existing unit suite",
+ "oracle": "test",
+ "verify": "objectui: pnpm --filter @object-ui/plugin-form exec vitest run src/discardGuard.test.tsx",
+ "evidence": "test output"
+ }
+ ],
+ "negative": [
+ "losing a dirty edit on close with NO prompt is the FAIL; equally, prompting on a pristine form is a paper-cut FAIL of the clean side"
+ ],
+ "traps": [
+ "automation-input"
+ ],
+ "automated": {
+ "kind": "unit",
+ "ref": "objectui: packages/plugin-form/src/discardGuard.test.tsx"
+ },
+ "source": [
+ "objectui: packages/plugin-form/src/{ModalForm,DrawerForm}.tsx (beforeunload/guard wiring)",
+ "dogfood-verification skill §4 (the beforeunload escape hatch exists precisely because this guard is real)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-07",
+ "change": "initial — the dirty-state guard existed (with a unit pin) but no checklist item asserted it",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.named-import-mapping",
+ "title": "A named import mapping maps foreign CSV headers and re-imports idempotently",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "api",
+ "personas": [
+ "admin"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the shipped mapping examples/app-showcase/src/data/mappings/ (showcase_inquiry_feed) targeting showcase_inquiry, with a CSV whose headers are Full Name / Channel (not the field names)"
+ ]
+ },
+ "steps": [
+ "POST /api/v1/data/showcase_inquiry/import with mappingName: 'showcase_inquiry_feed' and a foreign-header CSV",
+ "read back the created rows over /data",
+ "POST the SAME file again (idempotence probe)",
+ "POST with mappingName: 'no_such_mapping'"
+ ],
+ "acceptance": [
+ {
+ "clause": "foreign headers land on the mapped fields — 'Full Name' → the name field, 'Channel' → the source field — per the named mapping, not positional guessing",
+ "oracle": "api",
+ "verify": "created rows carry the CSV values on the mapped target fields",
+ "evidence": "the reads"
+ },
+ {
+ "clause": "re-importing the same file is idempotent (upsert on the mapping's key, e.g. email) — no duplicate rows",
+ "oracle": "api",
+ "verify": "row count unchanged after the second import",
+ "evidence": "before/after counts"
+ },
+ {
+ "clause": "an unknown mapping name fails loudly with a located error — never a silent positional fallback",
+ "oracle": "api",
+ "verify": "the bad-mapping response is a 4xx naming the missing mapping",
+ "evidence": "the response"
+ }
+ ],
+ "negative": [
+ "a duplicate-creating re-import (upsert key ignored) is a FAIL; so is a silent positional import when the named mapping is missing"
+ ],
+ "traps": [
+ "seed-data-thin"
+ ],
+ "source": [
+ "examples/app-showcase/src/data/mappings/ (showcase_inquiry_feed)",
+ "content/docs tour_data (named mapping claim)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "initial — the coverage.json `mapping` waiver was STALE (showcase ships showcase_inquiry_feed); un-waived",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.adhoc-filter-sort-builder",
+ "title": "User-built FilterBuilder/SortBuilder toolbar merges with the view filter, restores across nav, and reproduces rows from the URL",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)",
+ "a second account signed up on the same browser (for the user-scoping probe — sys_user rows come from sign-up)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_task with its saved views (examples/app-showcase/src/ui/views/task.view.ts) — the in_progress view carries a stored filter (status equals in_progress) so a toolbar condition can be proven to MERGE with, not replace, the view filter",
+ "10 seeded tasks spanning statuses/priorities/estimate_hours so a 2-condition toolbar filter returns a non-trivial subset (examples/app-showcase/src/data/seed/index.ts)"
+ ]
+ },
+ "steps": [
+ "sign in as admin; open showcase_task and switch to the in_progress saved view (stored filter status=in_progress); screenshot",
+ "open the FilterBuilder toolbar; add 2 conditions (e.g. priority = high, estimate_hours > 4); open the SortBuilder and add a sort (due_date desc); capture the data request",
+ "assert the request $filter is the MERGE of the view's stored rule AND the two toolbar conditions (buildEffectiveFilter/mergeFilterNodes), and $orderby carries the toolbar sort; the returned rows satisfy view-filter AND toolbar conditions",
+ "navigate away via an in-app nav link to another object (the link carries no query string, so the URL state is dropped), then navigate back to showcase_task in_progress; screenshot; confirm the toolbar filter + sort restored",
+ "read localStorage key list-filters::showcase_task: and confirm it embeds the signed-in user id",
+ "sign out; sign in as the SECOND account; open the same object+view; confirm the first account's cached toolbar filter does NOT appear",
+ "userFilters/URL: where UserFilters are hosted (an interface page or the userFilters surface — NOT a bare object list, ADR-0053), apply a quick-filter; copy the URL; open it in a fresh tab; confirm the uf_ params (comma-joined, URI-encoded) reproduce the same rows and uf__tab carries the active preset (ADR-0047)",
+ "negative sweep: confirm userFilters (quick-filter chips / uf_* params) do NOT leak onto the bare object list view"
+ ],
+ "acceptance": [
+ {
+ "clause": "the FilterBuilder toolbar conditions MERGE with the view's stored filter — the data request $filter carries BOTH the view rule AND the user conditions (buildEffectiveFilter over baseFilter + userFilter + normalized per-field conditions), never a replacement that drops the view filter",
+ "oracle": "network",
+ "verify": "captured GET /api/v1/data/showcase_task with $filter containing the view's status=in_progress node AND the two toolbar conditions merged (objectui plugin-list/src/ListView.tsx buildEffectiveFilter → mergeFilterNodes); rows returned satisfy every clause",
+ "evidence": "the request URL + first-page rows"
+ },
+ {
+ "clause": "the SortBuilder sort reaches the request as $orderby and the rows come back server-ordered (client re-sorting is not the oracle)",
+ "oracle": "network",
+ "verify": "the request carries $orderby for the toolbar sort field/direction; response row order matches",
+ "evidence": "the request URL + row order"
+ },
+ {
+ "clause": "the toolbar filter + search survive a FULL in-app navigation away and back — restored from localStorage (URL params alone are lost on an in-app nav link that carries no query string; listFilterStorage exists precisely to bridge that)",
+ "oracle": "dom",
+ "verify": "after a screenshot confirms the list rendered, the FilterBuilder shows the two conditions and the SortBuilder the sort; the request re-issued on return carries the same merged $filter (app-shell/src/views/listFilterStorage.ts)",
+ "evidence": "before/after screenshots + the re-issued request"
+ },
+ {
+ "clause": "the localStorage cache is USER-SCOPED — the key embeds the user id (list-filters:::) so a second account on the same browser never reads the first account's cached filters (a filter value can be sensitive)",
+ "oracle": "dom",
+ "verify": "read the storage key as account A, then sign in as account B and confirm B's view opens with no A-authored toolbar filter; anon falls to its own 'anon' bucket (buildListFilterKey)",
+ "evidence": "the two accounts' storage keys + B's clean toolbar"
+ },
+ {
+ "clause": "uf_* URL params make a filtered list shareable and reproducible: uf_ params (comma-joined, each URI-encoded) reproduce the same rows on a fresh load and uf__tab carries the active preset (ADR-0047)",
+ "oracle": "network",
+ "verify": "opening the copied URL fresh issues the same filtered data request and returns the same rows (app-shell/src/views/userFilterUrlState.ts parseUserFilterParams/applyUserFilterParams)",
+ "evidence": "the shared URL + the reproduced rows"
+ }
+ ],
+ "negative": [
+ "a toolbar filter that REPLACES the view's stored filter (rows appear that the view filter should exclude) is a FAIL — buildEffectiveFilter merges, it does not overwrite the base",
+ "userFilters (quick-filter chips / uf_* params) leaking onto a bare OBJECT list view is a FAIL — ADR-0053 suppresses them there by design (filter elements belong to interface pages; the list-view-capabilities item flags the same regression)",
+ "a cached toolbar filter from account A visible to account B on the same browser is a FAIL — the key embeds the user id precisely to prevent that leak",
+ "an incomplete FilterBuilder row emitted as [field, op, ''] (which matches only empty and silently excludes everything) instead of being dropped is a FAIL — convertFilterGroupToAST skips valueless rows (#1964)"
+ ],
+ "traps": [
+ "hydration-race",
+ "shared-browser-tab",
+ "stale-console-bundle"
+ ],
+ "automated": {
+ "kind": "e2e",
+ "ref": "objectui: e2e/live/user-filters.spec.ts, e2e/live/saved-view-filter.spec.ts"
+ },
+ "source": [
+ "objectui: packages/plugin-list/src/ListView.tsx (buildEffectiveFilter, convertFilterGroupToAST, mergeFilterNodes; $filter + $orderby assembly)",
+ "objectui: packages/app-shell/src/views/listFilterStorage.ts (user-scoped localStorage key, debounced write, clear)",
+ "objectui: packages/app-shell/src/views/userFilterUrlState.ts (uf_* params, ADR-0047)",
+ "ADR-0053 (userFilters belong to interface pages, suppressed on object list views); examples/app-showcase/src/ui/views/task.view.ts",
+ "cross-ref: records-forms.list-view-capabilities (saved-view-filter / userFilters negative)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "initial — deep-test the ad-hoc FilterBuilder/SortBuilder toolbar: view-filter merge + $orderby, localStorage nav-restore (user-scoped), uf_* URL reproduction, ADR-0053 leak negative",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.calendar-interactions",
+ "title": "Calendar is interactive: drag reschedules via PATCH, a failing PATCH reverts with an error, mode switches re-render, empty-day click quick-creates",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the showcase_task Calendar view (examples/app-showcase/src/ui/views/task.view.ts: named 'Calendar' view + allowedVisualizations calendar, calendar.startDateField due_date, titleField title, colorField status)",
+ "10 seeded tasks carrying due_date values so cards land on distinct days (examples/app-showcase/src/data/seed/index.ts)"
+ ]
+ },
+ "variants": [
+ "month-view card drag (move) → PATCH due_date",
+ "week/day time-grid move → PATCH start (+ end)",
+ "resize-end → PATCH end only",
+ "empty-day/slot click → quick-create"
+ ],
+ "steps": [
+ "open showcase_task; switch to the Calendar view; wait for cards to render (screenshot FIRST)",
+ "drag one task card to another day; capture the PATCH /api/v1/data/showcase_task/ carrying the new due_date; re-read the record over the API",
+ "reload the view; confirm the card renders on the new day from server state",
+ "force a FAILING reschedule (drive as a persona lacking edit, or drag a record the server rejects); observe the optimistic move, then the rollback to the original day AND an error toast",
+ "switch month → week → day modes; confirm the same records re-render placed by their date fields",
+ "click an empty day cell; a quick-create dialog opens pre-filled with that date; submit; capture the create and re-read the new row's due_date = the clicked day",
+ "capture one screenshot per interaction variant"
+ ],
+ "acceptance": [
+ {
+ "clause": "a card drag issues the record PATCH carrying the new date field(s) and the API re-read confirms the persisted value — pixels are not the oracle, the row is",
+ "oracle": "api",
+ "verify": "captured PATCH /api/v1/data/showcase_task/ with the new due_date (ObjectCalendar handleEventDropDefault → dataSource.update); GET re-read matches; reload re-renders from server state",
+ "evidence": "PATCH trace + re-read + post-reload screenshot"
+ },
+ {
+ "clause": "a FAILING PATCH REVERTS the optimistic move AND surfaces an error toast — never a silent snap-back that hides the failure (a 403 RLS denial is the common case)",
+ "oracle": "dom",
+ "verify": "after screenshot, the card returns to its original day and a visible error toast appears (ObjectCalendar rolls back setData(prevData) + toast.error, cloud#864); the geometry is unit-pinned by CalendarView.dnd.test.tsx",
+ "evidence": "before/after screenshots + the failed PATCH trace"
+ },
+ {
+ "clause": "month/week/day mode switches re-render the SAME records against their date fields (not a blank grid, not a grid fallback)",
+ "oracle": "dom",
+ "verify": "after screenshots at each mode, the seeded tasks appear placed by due_date/start; spot-check 2 against their API-read dates",
+ "evidence": "per-mode screenshots + 2 API reads"
+ },
+ {
+ "clause": "empty-day quick-create inserts a REAL row: the dialog pre-fills the clicked date, submit issues dataSource.create, and the API re-read shows the new row with its date field = the clicked day",
+ "oracle": "api",
+ "verify": "capture the create request and GET the new row; the start/due date equals the clicked cell's date (ObjectCalendar quick-create → dataSource.create)",
+ "evidence": "create trace + the row read"
+ }
+ ],
+ "negative": [
+ "a drag that repaints the card but lands NO PATCH (or a 4xx PATCH while the card keeps the new day) is a FAIL — the calendar can lie visually, the same class as the gantt §8 rows",
+ "a failed reschedule that silently snaps back with NO error surfaced is a FAIL — the code explicitly rolls back AND toasts; a silent revert would hide a real RLS denial"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input"
+ ],
+ "automated": {
+ "kind": "unit",
+ "ref": "objectui: packages/plugin-calendar/src/CalendarView.dnd.test.tsx (move/resize/time-grid drag geometry)"
+ },
+ "source": [
+ "objectui: packages/plugin-calendar/src/ObjectCalendar.tsx (handleEventDropDefault optimistic update + rollback + toast; empty-day quick-create → dataSource.create)",
+ "objectui: packages/plugin-calendar/src/CalendarView.tsx + CalendarView.dnd.test.tsx",
+ "examples/app-showcase/src/ui/views/task.view.ts (Calendar view: startDateField due_date, colorField status)",
+ "cross-ref: records-forms.view-type-gallery (calendar render-only), records-forms.kanban-drag-persistence / gantt-interactions (sibling drag-persist items)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "initial — calendar was render-only in view-type-gallery; this deep-tests the drag→PATCH→revert chain, mode switches, and empty-day quick-create",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.concurrent-edit-conflict",
+ "title": "Concurrent edits collide loudly (OCC 409 + conflict dialog), never silent last-write-wins; inline two-surface edit is ONE atomic OCC-guarded save",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "mixed",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123) in TWO independent browser sessions (distinct auth cookies), 'A' and 'B'"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "any writable object whose rows carry an updated_at OCC token (e.g. showcase_account or showcase_task) — the form reads updated_at and sends it as If-Match (objectui plugin-form/src/occSave.tsx; framework packages/metadata-protocol/src/protocol.ts updateData If-Match handling)",
+ "a record with both a details-body field and a header-highlight field for the two-surface inline-edit fold-in (RecordDetailView highlight fields)"
+ ]
+ },
+ "steps": [
+ "open the SAME record's detail in session A and session B (both hold the same updated_at)",
+ "in A: edit one field, Save; capture the PATCH carrying If-Match: → 200; the record's updated_at advances",
+ "in B (still holding the STALE updated_at): edit the same field, Save; capture the response",
+ "assert B receives 409 CONCURRENT_UPDATE and the conflict dialog appears (Reload latest / Overwrite) — NOT a silent overwrite, NOT a bare error",
+ "in B choose Reload → the form refetches and shows A's value",
+ "in B re-edit and choose Overwrite → the retry re-keys ifMatch to the version the 409 reported → 200; re-read shows exactly one winner (B's value)",
+ "INLINE two-surface (§E1): on the detail, enter the shared inline-edit session; edit one details-BODY field AND one header-HIGHLIGHT field; confirm ONE Save bar; Save; capture the single PATCH and its keys + headers",
+ "stale-ifMatch inline path: repeat the inline save holding a stale updated_at (a concurrent write landed between) and capture the outcome"
+ ],
+ "acceptance": [
+ {
+ "clause": "the losing save is REFUSED with 409 CONCURRENT_UPDATE server-side — never a silent last-write-wins that overwrites A with no signal",
+ "oracle": "api",
+ "verify": "B's PATCH returns HTTP 409 with code CONCURRENT_UPDATE (framework rest-server.ts error mapping: error.code CONCURRENT_UPDATE / ConcurrentUpdateError → 409); A's value is intact on an independent re-read",
+ "evidence": "B's 409 response + the intact re-read"
+ },
+ {
+ "clause": "the conflict surfaces a conflict DIALOG offering Reload/Overwrite (Keep editing) — a structured choice, not a silent overwrite and not a raw stack trace",
+ "oracle": "dom",
+ "verify": "after screenshot, the ConcurrentUpdateDialog (plugin-detail) / occSave conflict dialog (plugin-form) renders with the racer's version and the two actions",
+ "evidence": "the conflict-dialog screenshot"
+ },
+ {
+ "clause": "Reload DISCARDS B's pending edit and refetches — B's form then shows A's value",
+ "oracle": "api",
+ "verify": "after Reload, the form's field equals A's saved value (a fresh GET), and no B write landed",
+ "evidence": "the refetch read + no-write trace"
+ },
+ {
+ "clause": "Overwrite re-keys ifMatch to the version the 409 reported and lands EXACTLY ONE winner on re-read — an explicit last-write chosen by the user, not an accident",
+ "oracle": "api",
+ "verify": "the overwrite retry carries the 409-reported currentVersion as ifMatch → 200; the final re-read shows B's value, with no lost-update ambiguity (occSave settle/retry path)",
+ "evidence": "the overwrite PATCH + final re-read"
+ },
+ {
+ "clause": "inline two-surface edit is ONE atomic save: editing one details-body field AND one header-highlight field drives ONE Save bar and issues ONE PATCH carrying EXACTLY those two keys plus ifMatch = the read updated_at (the draft holds changed keys only — never computed/readonly/untouched fields)",
+ "oracle": "network",
+ "verify": "the single PATCH body has exactly the two edited keys and an If-Match header (objectui InlineEditSaveBar dataSource.update(obj,id,draft,{ifMatch:data.updated_at}); InlineEditContext draft = changed keys; pinned family e2e/live/inline-edit-polish-2572.spec.ts)",
+ "evidence": "the single PATCH body + headers"
+ },
+ {
+ "clause": "the inline path is OCC-guarded too: an inline save on a stale ifMatch → 409 conflict (same dialog), not a silent overwrite",
+ "oracle": "api",
+ "verify": "the stale inline PATCH returns 409 CONCURRENT_UPDATE and the conflict dialog appears",
+ "evidence": "the 409 + dialog"
+ }
+ ],
+ "negative": [
+ "silent last-write-wins — B's save overwriting A with no 409 and no dialog — is THE FAIL this item exists to catch",
+ "an inline two-surface edit that issues TWO PATCHes (one per surface) or a single PATCH carrying untouched/computed/readonly keys is a FAIL — one atomic save, changed keys only",
+ "an inline or form save that omits the If-Match header (an unguarded write) is a FAIL — the OCC token must ride or a concurrent overwrite goes undetected"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input",
+ "shared-browser-tab",
+ "stale-console-bundle"
+ ],
+ "automated": {
+ "kind": "unit",
+ "ref": "objectui: packages/plugin-form/src/occSave.test.tsx (409 handling + overwrite retry); e2e/live/inline-edit-polish-2572.spec.ts (single save bar / atomic inline save)"
+ },
+ "source": [
+ "objectui: packages/plugin-detail/src/ConcurrentUpdateDialog.tsx (Reload/Overwrite UX), packages/plugin-form/src/occSave.tsx (If-Match → 409, conflict dialog, re-key overwrite)",
+ "objectui: packages/plugin-detail/src/InlineEditSaveBar.tsx + packages/react/src/context/InlineEditContext.tsx (one save bar, changed-keys draft, ifMatch)",
+ "framework: packages/rest/src/rest-server.ts (CONCURRENT_UPDATE → 409 mapping), packages/metadata-protocol/src/protocol.ts updateData (If-Match / expectedVersion)",
+ "PENDING-GAPS §C concurrent-edit-conflict + §E1 inline-edit atomic two-surface (objectui#2542/2549/2604); cross-ref records-forms.crud-roundtrip"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "initial — deep-test OCC conflict (409 + dialog, reload/overwrite) and fold in the §E1 inline-edit atomic two-surface behavior (ONE save bar / ONE PATCH / ifMatch)",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.record-discussion-mentions",
+ "title": "Record discussion: an @mention comment reconciles optimistically, persists to sys_comment, interleaves with activity, and pings the mentioned user's bell",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123) — the comment author",
+ "a second signed-up user — the @mention TARGET (sys_user rows come from sign-up, not seeds)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "a showcase object with feeds enabled (default — enable.feeds !== false) so the discussion panel mounts and sys_comment writes are accepted (packages/spec/src/data/object.zod.ts feeds default true; plugin-audit enforceFeedsCapability)",
+ "a record to comment on (e.g. a seeded showcase_task or showcase_account)"
+ ],
+ "knownGaps": [
+ "the @mention TARGET and the bell/notification clauses need a SECOND signed-up user — a fresh single-user boot cannot exercise them; record those clauses blocked(fixture) and run the optimistic-reconcile + sys_comment + activity-interleave clauses as the single author"
+ ]
+ },
+ "steps": [
+ "as admin, open a record detail with the discussion panel (feeds enabled); screenshot",
+ "type a comment containing an @mention (the mention autocomplete offers mentionSuggestions); post it",
+ "observe the OPTIMISTIC comment row appear immediately; capture the sys_comment create",
+ "after the feed refetch, confirm the optimistic row reconciles with the server row (same id → union-by-id merge, no duplicate, no phantom)",
+ "read sys_comment over the API: GET /api/v1/data/sys_comment?$filter for the record's thread_id, ordered by created_at asc — the posted comment is present",
+ "perform an attributable change on the record (e.g. an edit) and confirm the resulting sys_activity row interleaves into the SAME feed, oldest-first",
+ "as the mentioned second user, open the notification bell — it gains the mention notification with a deep link back to the record; unreadCount increments; mark-as-read drops it",
+ "confirm a NON-mentioned user's bell does NOT gain the notification (recipient scoping)"
+ ],
+ "acceptance": [
+ {
+ "clause": "the posted comment PERSISTS to sys_comment (thread_id-scoped, created_at-ordered) and the API read returns it — a success toast is not the oracle, the row is",
+ "oracle": "api",
+ "verify": "GET /api/v1/data/sys_comment filtered by the record's thread_id returns the comment with its body (RecordDetailView sys_comment fetch: dataSource.find('sys_comment', {$filter:{thread_id}, $orderby:{created_at:'asc'}}))",
+ "evidence": "the sys_comment read"
+ },
+ {
+ "clause": "the OPTIMISTIC row reconciles with the server row by id — no duplicate, no phantom (the create uses the same id the refetch returns; union-by-id keeps one)",
+ "oracle": "dom",
+ "verify": "after screenshot, the feed shows exactly ONE row for the posted comment across the post→refetch transition (RecordDetailView mergeFeedRows: Map by String(id), server copy wins on the same key)",
+ "evidence": "post + post-refetch feed screenshots"
+ },
+ {
+ "clause": "sys_activity rows INTERLEAVE into the same discussion feed, oldest-first, alongside the comments (one feed, two tables)",
+ "oracle": "dom",
+ "verify": "after an attributable change, its sys_activity row appears in time order among the comment rows (mergeFeedRows sorts by createdAt; recordActivityFeed.ts maps sys_activity.type → FeedItemType)",
+ "evidence": "the interleaved feed screenshot + the sys_activity read"
+ },
+ {
+ "clause": "the mentioned user's bell gains the notification with a WORKING deep link to the record, and unreadCount increments; mark-as-read / mark-all-read drop the count",
+ "oracle": "dom",
+ "verify": "as the mentioned user, the bell shows the new mention notification, its link navigates to the commented record, and reading it decrements unreadCount (collaboration/useMentionNotifications)",
+ "evidence": "bell screenshots before/after + the deep-link navigation"
+ },
+ {
+ "clause": "notifications are RECIPIENT-scoped both sides: only the @mentioned user's bell gains it (addNotification is gated recipientId === currentUserId); a non-mentioned user's bell does not",
+ "oracle": "dom",
+ "verify": "the mentioned user sees it; a second, non-mentioned session does not (useMentionNotifications recipient gate)",
+ "evidence": "both users' bells"
+ }
+ ],
+ "negative": [
+ "an optimistic comment that stays as a SECOND row after the server row lands (dupe) or vanishes entirely (phantom) is a FAIL — mergeFeedRows exists to keep exactly one",
+ "a mention notification delivered to a NON-mentioned user is a FAIL — the recipientId gate is the boundary",
+ "feeds:false must HIDE the panel, SKIP the sys_comment fetch, AND the server must reject new comments with 403 FEEDS_DISABLED — a silent no-op that accepts a comment nowhere-readable is a FAIL"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input",
+ "seed-data-thin"
+ ],
+ "source": [
+ "objectui: packages/plugin-detail/src/{RecordChatterPanel,CommentInput,MentionAutocomplete,extractMentions}.tsx (compose + @mention)",
+ "objectui: packages/app-shell/src/views/RecordDetailView.tsx (mergeFeedRows union-by-id, sys_comment + sys_activity fetch/merge, mentionSuggestions), packages/plugin-detail/src/renderers/recordActivityFeed.ts (activity→feed map)",
+ "objectui: packages/collaboration/src/useMentionNotifications.ts (recipient-scoped bell, unreadCount)",
+ "framework: packages/spec/src/data/object.zod.ts (enable.feeds default true → FEEDS_DISABLED); PENDING-GAPS §B record-discussion-mentions"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "initial — deep-test the record discussion: optimistic reconcile, sys_comment persistence, activity interleave, recipient-scoped mention bell",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.grid-personalization",
+ "title": "Grid personalization persists across reload: column resize/reorder/pin, row-height, group-by (with API-true totals), row-color",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_task grid view with a groupable field (status) and a summarizable field (estimate_hours) — examples/app-showcase/src/ui/views/task.view.ts; 10 seeded tasks across statuses (examples/app-showcase/src/data/seed/index.ts)"
+ ]
+ },
+ "variants": [
+ "column-resize",
+ "column-reorder",
+ "column-pin",
+ "row-height",
+ "group-by",
+ "row-color"
+ ],
+ "steps": [
+ "open showcase_task grid; screenshot",
+ "resize a column, reorder two columns, pin a column, and set a row-height mode (compact/short/medium/tall/extra_tall)",
+ "reload; confirm the columnState (widths, order, pinned) and row-height restored from localStorage grid-columns-showcase_task- (or the persisted view override via dataSource.updateViewConfig)",
+ "group-by status; confirm per-group headers appear and each group shows its summarizeField (estimate_hours) total; clear grouping and confirm the headers are removed cleanly",
+ "cross-check one group's total against a direct API aggregate: GET /api/v1/data/showcase_task grouped client-side from the raw rows",
+ "set a row-color rule; confirm rows paint by the rule (useRowColor)",
+ "reload once more; confirm every personalization survives; capture a screenshot per variant"
+ ],
+ "acceptance": [
+ {
+ "clause": "PER-VARIANT: column resize + reorder + pin persist across a reload — the columnState (widths/order/pinned) restores from localStorage (grid-columns--) or a persisted view override; a repaint that does NOT survive reload fails that variant",
+ "oracle": "dom",
+ "verify": "after a screenshot confirms render, the reloaded grid shows the same widths/order/pinned column (ObjectGrid columnState priority: props override > localStorage > empty; saveColumnState writes both)",
+ "evidence": "before/after-reload screenshots + the storage value"
+ },
+ {
+ "clause": "row-height mode persists across reload (one of compact/short/medium/tall/extra_tall)",
+ "oracle": "dom",
+ "verify": "the reloaded grid renders at the chosen density (ObjectGrid rowHeightMode)",
+ "evidence": "before/after screenshots"
+ },
+ {
+ "clause": "group-by renders per-group headers and CLEARS cleanly, and the grouped totals MATCH a direct API aggregate of the grouping/summary field — grouping summarizes the real rows, not the rendered page",
+ "oracle": "api",
+ "verify": "compare a group's summarizeField total against GET /api/v1/data/showcase_task aggregated client-side (ObjectGrid grouping + useColumnSummary); clearing grouping removes the headers",
+ "evidence": "board screenshot + the aggregate check"
+ },
+ {
+ "clause": "row-color rules paint rows by the rule (useRowColor), and survive a reload with the rest of the personalization",
+ "oracle": "screenshot",
+ "verify": "after reload the colored rows match the rule against the seeded values",
+ "evidence": "post-reload screenshot"
+ },
+ {
+ "clause": "the column-state store is per-browser and deliberately NOT user-scoped (cosmetic), UNLIKE the runtime filter store — so a shared column layout across accounts on one browser is by-design, not a leak to fail on",
+ "oracle": "dom",
+ "verify": "the key is grid-columns-[-] with no user id (listFilterStorage.ts documents the deliberate asymmetry: widths cosmetic, filter values sensitive)",
+ "evidence": "the storage key"
+ }
+ ],
+ "negative": [
+ "a personalization that repaints but does NOT survive reload is a FAIL — the persistence, not the repaint, is the contract",
+ "a group total that disagrees with the API aggregate is a FAIL — the grouping must summarize the real matching rows, not just the rendered page"
+ ],
+ "traps": [
+ "hydration-race",
+ "stale-console-bundle"
+ ],
+ "automated": {
+ "kind": "unit",
+ "ref": "objectui: packages/plugin-grid/src/__tests__/{groupedPagination,groupedBooleanLabel,inlineEditPersistence}.test.tsx"
+ },
+ "source": [
+ "objectui: packages/plugin-grid/src/ObjectGrid.tsx (columnState + saveColumnState localStorage grid-columns-*, rowHeightMode, schema.grouping, useRowColor, useColumnSummary)",
+ "objectui: packages/components/src/renderers/complex/data-table.tsx (the underlying table)",
+ "objectui: packages/app-shell/src/views/listFilterStorage.ts (documents grid-columns-* is NOT user-scoped by design)",
+ "examples/app-showcase/src/ui/views/task.view.ts"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "initial — deep-test grid personalization persistence (columns/row-height/grouping/row-color) with API-true grouping totals",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.saved-view-management",
+ "title": "Admin saved-view lifecycle: create kanban via dialog, rename/set-default/pin/delete each hit the meta overlay and survive reload; non-admin affordances absent and server-refused",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)",
+ "a non-admin user (e.g. bound to showcase_member_default) — sign one up; sys_user rows cannot be seeded"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "a showcase object with a groupable field for the kanban groupByField (showcase_task, status) — examples/app-showcase/src/ui/views/task.view.ts",
+ "the console Create View / Manage Views dialogs (objectui CreateViewDialog.tsx, ManageViewsDialog.tsx)"
+ ],
+ "knownGaps": [
+ "the non-admin refusal clauses need a signed-up non-admin user bound to a member set — record them blocked(fixture) on a single-user boot"
+ ]
+ },
+ "variants": [
+ "create-kanban",
+ "rename",
+ "set-default",
+ "pin",
+ "delete"
+ ],
+ "steps": [
+ "as admin, open showcase_task; open the Create View dialog; choose kanban; set groupByField = status; create — capture PUT /api/v1/meta/view/ (meta.saveItem) carrying {type:'kanban', kanban:{groupByField:'status'}}",
+ "reload; confirm the new kanban view is live and groups by status",
+ "rename the view via Manage Views; capture the PUT; reload confirms the new label",
+ "set the view as default (unsets the prior default); capture the PUT(s); reload confirms the default moved",
+ "pin the view; capture the PUT (isPinned); reload confirms it pinned",
+ "delete the view; capture DELETE /api/v1/meta/view/; reload confirms it is gone",
+ "as the non-admin, open the same object; confirm the create-view and manage-views (rename/default/pin/delete) affordances are ABSENT",
+ "as the non-admin, forge a direct PUT /api/v1/meta/view/; capture the refusal"
+ ],
+ "acceptance": [
+ {
+ "clause": "creating a kanban view via CreateViewDialog persists a PUT /api/v1/meta/view/ carrying the type and its config (kanban.groupByField), and the view is LIVE after reload — a local repaint that never reached the overlay is not enough",
+ "oracle": "network",
+ "verify": "captured PUT /api/v1/meta/view/ (data-objectstack createView → client.meta.saveItem('view', name, spec); framework route PUT /api/v1/meta/:type/:name, client meta.saveItem); reload re-reads it via listViews",
+ "evidence": "the PUT payload + post-reload view list"
+ },
+ {
+ "clause": "rename / set-default / pin / delete each issue their meta write and are live after reload; set-default UNSETS the prior default (updateView isDefault flips all others off)",
+ "oracle": "network",
+ "verify": "one PUT per rename/pin/isDefault (updateView read-merge-write) and a DELETE /api/v1/meta/view/ for delete (deleteView → meta.deleteItem); reload reflects each",
+ "evidence": "the four traces + post-reload states"
+ },
+ {
+ "clause": "the non-admin lacks the mutation affordances: the create-view control and the Manage Views rename/default/pin/delete actions are ABSENT (ManageViewsDialog suppresses mutation affordances for system/read-only)",
+ "oracle": "dom",
+ "verify": "after a screenshot confirms the object opened, the DOM shows no create-view / manage mutation controls for the non-admin",
+ "evidence": "the non-admin screenshot"
+ },
+ {
+ "clause": "the SERVER is the boundary: a forged PUT /api/v1/meta/view/ by the non-admin is refused (4xx) — UI absence alone is courtesy",
+ "oracle": "api",
+ "verify": "the direct non-admin PUT returns a 403-class refusal and no overlay view is created (RUNNER rule 4, ADR-0057 D10)",
+ "evidence": "the forged-request refusal"
+ }
+ ],
+ "negative": [
+ "a view mutation that repaints locally but does NOT survive reload (never reached the meta overlay) is a FAIL",
+ "a non-admin able to persist a meta view via a forged PUT is a FAIL — both sides of the gate",
+ "set-default that leaves TWO views flagged default (the prior one not unset) is a FAIL"
+ ],
+ "traps": [
+ "hydration-race",
+ "wrong-persona"
+ ],
+ "source": [
+ "objectui: packages/app-shell/src/views/CreateViewDialog.tsx (kanban groupByField), packages/plugin-view/src/ManageViewsDialog.tsx (rename/default/pin/delete affordances, read-only suppression)",
+ "objectui: packages/app-shell/src/views/ObjectView.tsx (updateView/deleteView wiring), packages/data-objectstack/src/index.ts (createView/updateView/deleteView → meta.saveItem/deleteItem)",
+ "framework: packages/rest/src/rest-route-ledger.ts:94-95 (PUT/DELETE /api/v1/meta/:type/:name)",
+ "PENDING-GAPS §C saved-view-management; cross-ref studio-authoring.view-authoring-live (distinct — this is the runtime end-user/admin lifecycle)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "initial — deep-test the saved-view lifecycle (create-kanban/rename/default/pin/delete) against the meta overlay, with the non-admin both-sides gate",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.bulk-select-all-matching",
+ "title": "Select-all-matching covers every matching id across pages (server total, not the rendered page); clear resets both selection sources",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "a list whose filtered result exceeds one page: showcase_contact filtered to Northwind's account has 26 rows (2 named + 24 'Prospect NN'), authored to exceed a page (examples/app-showcase/src/data/seed/index.ts, objectui#2711)",
+ "a bulk action registered on the object so the BulkActionBar and its aggregate/per-record dispatch are exercisable (objectui plugin-grid BulkActionBar)"
+ ]
+ },
+ "steps": [
+ "open the showcase_contact list filtered so the match set exceeds one page (e.g. account = Northwind → 26 rows); screenshot; note page shows N rows, count bar shows server total M (M > N)",
+ "select the whole visible page; the cross-page banner appears offering 'Select all M matching'",
+ "click select-all-matching; the banner reads 'All M matching records are selected'",
+ "dispatch a bulk action; capture the requests — resolveBulkRows fans out a paged find (dropping $top/$skip, paging 500) collecting every matching id ACROSS pages",
+ "cross-check the collected id count against a direct filtered API count: GET /api/v1/data/showcase_contact?$filter=...&$top=0 (or the response total)",
+ "confirm an AGGREGATE action carries every matching id in params._selectedIds; a per-record action fans out one dispatch per matching id",
+ "click clear; confirm both selection sources reset (the toolbar empties AND the row checkboxes clear)"
+ ],
+ "acceptance": [
+ {
+ "clause": "the cross-page banner reports the SERVER total (totalMatching from the find result.total), not the rendered page count — and only appears when the whole page is selected and more matching rows exist off-screen",
+ "oracle": "network",
+ "verify": "the banner's count equals the data request's response total (BulkActionBar totalMatching; ObjectGrid captures result.total into totalMatching)",
+ "evidence": "banner screenshot + the find response total"
+ },
+ {
+ "clause": "select-all-matching resolves EVERY matching id across pages: the dispatched set equals a direct filtered API count, not just the visible page (paged fan-out with $top/$skip dropped, capped at 5000)",
+ "oracle": "api",
+ "verify": "count the ids handed to the executor (resolveBulkRows collected set) vs GET /api/v1/data/showcase_contact filtered total; they match (up to the HARD_CAP)",
+ "evidence": "the collected-id count + the filtered API count"
+ },
+ {
+ "clause": "an AGGREGATE bulk action issues ONE dispatch carrying the full matching id set in params._selectedIds; a per-record action fans out one dispatch per matching id",
+ "oracle": "network",
+ "verify": "count captured POSTs against the match-set size per mode (ObjectGrid dispatchBulkAction → resolveBulkRows → params._selectedIds)",
+ "evidence": "the dispatch trace(s)"
+ },
+ {
+ "clause": "clear resets BOTH selection sources — the toolbar selectedRows AND the data-table row checkboxes (selectionResetKey) — so no ticked rows are stranded with no toolbar to act on them (#3056)",
+ "oracle": "dom",
+ "verify": "after clear, both the toolbar and the row checkboxes are empty (resetSelection: setSelectedRows([]) + setSelectAllMatching(false) + bump selectionResetKey)",
+ "evidence": "post-clear screenshot"
+ },
+ {
+ "clause": "the fan-out is bounded (HARD_CAP 5000): a match set beyond the cap is handled coherently (truncated to the cap, not silently claiming to cover everything) — note the weakness in evidence where the set is large",
+ "oracle": "network",
+ "verify": "the collected set never exceeds 5000; where the match set is larger, the run records the cap rather than asserting full coverage",
+ "evidence": "the collected-set size vs the server total"
+ }
+ ],
+ "negative": [
+ "a 'select all matching' that only acts on the RENDERED page (ids = page rows, not the server match set) is THE FAIL this item extends list-view-capabilities (page-local bulk) to catch",
+ "a banner total that disagrees with the filtered API count is a FAIL",
+ "a clear that leaves the row checkboxes ticked while the toolbar empties (or vice versa) is a FAIL (#3056 drift)"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input"
+ ],
+ "automated": {
+ "kind": "unit",
+ "ref": "objectui: packages/plugin-grid/src/__tests__/{objectBulkActionDispatch,BulkActionBar,bulkActionRefresh}.test.tsx"
+ },
+ "source": [
+ "objectui: packages/plugin-grid/src/ObjectGrid.tsx (selectAllMatching, resolveBulkRows paged fan-out + HARD_CAP, resetSelection #3056, totalMatching), packages/plugin-grid/src/components/BulkActionBar.tsx (cross-page banner)",
+ "examples/app-showcase/src/data/seed/index.ts (Northwind 26-contact fixture, objectui#2711)",
+ "PENDING-GAPS §C bulk-select-all-matching; cross-ref records-forms.list-view-capabilities (bulk-actions, page-local)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "initial — deep-test cross-page select-all-matching: server total, across-pages id coverage vs a filtered API count, clear-resets-both (extends the page-local bulk in list-view-capabilities)",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.lookup-picker-create-new",
+ "title": "Lookup picker quick-create: a user-facing zero-hit lookup opens the referenced create form and adopts the new id; system references offer no quick-create",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123) — holds create on the referenced object"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "a user-facing lookup with allowCreate: showcase_business_unit.parent (Field.lookup('showcase_business_unit', {allowCreate:true})) or showcase_category.parent (allowCreate:true) — examples/app-showcase/src/data/objects/{business-unit,category}.object.ts",
+ "a system/user-directory reference for the negative side (any sys_/cloud_/ai_ reference, or a user/users directory field) — the picker must NOT offer quick-create there"
+ ]
+ },
+ "steps": [
+ "open New Business Unit (or New Category); open the parent lookup picker",
+ "type a search term with ZERO hits; confirm the picker issues a SCOPED server find against the referenced object and shows a Create-new affordance (labelled with the typed name)",
+ "click create-new; the referenced object's FULL create form opens (via the ActionProvider modal); fill required fields and save",
+ "confirm the picker ADOPTS the newly created id and the parent form's field now holds it",
+ "save the parent; re-read the parent over the API — its reference resolves to the new id",
+ "type a search WITH hits; confirm each keystroke issues a scoped server request (not client filtering of a preloaded set)",
+ "open a lookup to a system/user-directory reference (sys_/cloud_/ai_ or user/users); confirm NO create-new affordance is offered"
+ ],
+ "acceptance": [
+ {
+ "clause": "a zero-hit USER-FACING lookup offers create-new, opening the referenced object's FULL create form (allowCreate is default-on for user-facing relations so a fresh app is not a dead end)",
+ "oracle": "dom",
+ "verify": "after a screenshot confirms the picker, the create-new control is present and clicking it opens the referenced object's create form (LookupField isUserFacingReference + handleCreateNew)",
+ "evidence": "picker screenshot + the create-form open"
+ },
+ {
+ "clause": "saving the create form makes the picker ADOPT the new id, and the parent save re-reads with the reference resolving to that exact id",
+ "oracle": "api",
+ "verify": "GET the parent after save; the lookup field holds the created child's real id (not a placeholder/stale id)",
+ "evidence": "the parent re-read + the child id"
+ },
+ {
+ "clause": "typed search issues a SCOPED server find against the referenced object (server-side search, not client-side filtering of a preloaded set)",
+ "oracle": "network",
+ "verify": "captured requests carry the search scoped to the referenced object (RecordPickerDialog → DataSource.find with the query)",
+ "evidence": "the search request traces"
+ },
+ {
+ "clause": "system references offer NO quick-create: a lookup to a sys_/cloud_/ai_ object or the user/users directory shows no create-new affordance — you must not mint plumbing rows inline",
+ "oracle": "dom",
+ "verify": "after screenshot, the system-reference picker has no create-new control (SYSTEM_REFERENCE_RX /^(sys_|cloud_|ai_)/ + USER_DIRECTORY_REFS {user,users}; sys_user matches the RX)",
+ "evidence": "the system-reference picker screenshot"
+ }
+ ],
+ "negative": [
+ "a create-new offered on a sys_/cloud_/ai_ or user-directory reference is a FAIL — isUserFacingReference excludes them by design",
+ "a picker that filters a preloaded CLIENT set instead of issuing a scoped server search is a FAIL — the unscoped fetch is the defect",
+ "the parent adopting a stale/placeholder id instead of the created row's real id is a FAIL"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input"
+ ],
+ "automated": {
+ "kind": "unit",
+ "ref": "objectui: packages/fields/src/widgets/{LookupField.dependsOn,RecordPickerDialog.filterOptions}.test.tsx (adjacent picker pins)"
+ },
+ "source": [
+ "objectui: packages/fields/src/widgets/LookupField.tsx (allowCreate default-on, SYSTEM_REFERENCE_RX / USER_DIRECTORY_REFS, isUserFacingReference, handleCreateNew), packages/fields/src/widgets/RecordPickerDialog.tsx (scoped find)",
+ "examples/app-showcase/src/data/objects/business-unit.object.ts + category.object.ts (parent allowCreate:true)",
+ "PENDING-GAPS §B lookup-picker-create-new"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "initial — deep-test lookup quick-create: create-new opens the referenced form + id adoption + parent re-read, scoped search, and the sys_/user-directory no-quick-create both-sides",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.record-edit-undo",
+ "title": "Record edit undo: toast Undo / Ctrl+Z restores the prior value through the API and logs a revert; undo after a concurrent change must not silently clobber",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "browser",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)",
+ "a second session (for the concurrent-clobber probe)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "a writable record and an undoable edit path (an inline edit or an action flagged undoable) — objectui react/src/hooks/useGlobalUndo.ts + RecordDetailView toast Undo action; core globalUndoManager"
+ ]
+ },
+ "steps": [
+ "open a record; make an undoable edit to one field; confirm the success toast shows an Undo button",
+ "click Undo (or press Ctrl+Z); capture the resulting update; re-read the record over the API and confirm the prior value is restored",
+ "open the History/audit and confirm a revert entry was added (a forward write, not a silent rollback)",
+ "press Ctrl+Shift+Z (redo); confirm the edited value is restored",
+ "CONCURRENT-CLOBBER probe: in session A make the undoable edit; in session B change the SAME field and save; back in A, click Undo",
+ "observe and FLAG whether A's undo carries an OCC guard or silently overwrites B's value"
+ ],
+ "acceptance": [
+ {
+ "clause": "undo restores the prior value through the dataSource (a REAL write) and the API re-read shows it — not merely a local UI rollback",
+ "oracle": "api",
+ "verify": "after Undo, GET the record; the field equals its pre-edit value (useGlobalUndo executeOp → dataSource.update(objectName, recordId, op.undoData))",
+ "evidence": "the re-read + the undo update trace"
+ },
+ {
+ "clause": "both the toast Undo button and Ctrl+Z trigger undo, and Ctrl+Shift+Z redoes — the same globalUndoManager stack",
+ "oracle": "dom",
+ "verify": "the toast action and the keyboard shortcut both restore the prior value; redo re-applies the edit (RecordDetailView toast action onClick undoCtl.undo; useGlobalUndo Ctrl+Z / Ctrl+Shift+Z)",
+ "evidence": "screenshots of both paths"
+ },
+ {
+ "clause": "the revert is auditable: the History/audit gains a NEW entry for the undo (undo is a forward write, not a hidden state reset)",
+ "oracle": "api",
+ "verify": "after screenshot of the History tab, the audit/read shows the revert as its own update",
+ "evidence": "the History entries + audit read"
+ },
+ {
+ "clause": "CONCURRENT-CLOBBER (observe + flag): the undo path issues a bare dataSource.update with NO ifMatch, so an undo after a concurrent edit landed in between will overwrite it — verify the observed behavior and record a silent clobber as a finding, not a silent pass",
+ "oracle": "api",
+ "verify": "in the two-session probe, read whether A's undo carries an If-Match / triggers a 409, or silently overwrites B's value (useGlobalUndo executeOp: update(op.objectName, op.recordId, data) — no OCC token)",
+ "evidence": "the undo request headers + B's value before/after A's undo"
+ }
+ ],
+ "negative": [
+ "an undo that only repaints the field but does NOT persist (no API write, or the re-read still shows the edited value) is a FAIL — the restore must round-trip the server",
+ "an undo silently overwriting a concurrent edit with no conflict signal is the behavior to FLAG as an expected-risk finding (the undo path is unguarded) — recording it as a clean pass would hide a lost-update hole"
+ ],
+ "traps": [
+ "automation-input",
+ "hydration-race",
+ "shared-browser-tab"
+ ],
+ "automated": {
+ "kind": "unit",
+ "ref": "objectui: packages/core/src/actions/__tests__/UndoManager.test.ts (undo/redo stack)"
+ },
+ "source": [
+ "objectui: packages/react/src/hooks/useGlobalUndo.ts (executeOp, undo/redo, Ctrl+Z/Ctrl+Shift+Z), packages/app-shell/src/views/RecordDetailView.tsx (success-toast Undo action), @object-ui/core globalUndoManager",
+ "PENDING-GAPS §C record-edit-undo (concurrent-clobber flag)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "initial — deep-test edit undo: API-true restore via toast/Ctrl+Z, auditable revert, and the unguarded concurrent-clobber observation",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.import-job-undo-cancel",
+ "title": "Async import job: terminal state + per-row results, undo removes exactly the imported rows, cancel mid-job leaves a coherent partial",
+ "since": "v17",
+ "status": "active",
+ "revision": 1,
+ "priority": "P2",
+ "surface": "mixed",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "an import-job-capable client wired into the console AND an object with an async-import route (framework packages/rest/src/rest-route-ledger.ts: POST /api/v1/data/:object/import/jobs, /import/jobs/:jobId/{cancel,undo,results})",
+ "the objectui import specs, which self-gate on IMPORT_CONSOLE_LIVE=1 (console) or a reachable import harness /live.html"
+ ],
+ "knownGaps": [
+ "the async import + undo/cancel path is gated: e2e/import-console/import-console-undo.spec.ts skips unless IMPORT_CONSOLE_LIVE=1 with an import-job-capable client wired; e2e/import-harness/import-undo.spec.ts skips unless the harness origin serves /live.html — on a stock showcase boot these do not run, so record blocked(fixture)"
+ ]
+ },
+ "blocked": {
+ "by": "fixture",
+ "ref": "IMPORT_CONSOLE_LIVE / import-harness gate — objectui e2e/import-console/import-console-undo.spec.ts self-skips unless IMPORT_CONSOLE_LIVE=1 and an import-job-capable client is wired; PENDING-GAPS §C import-job-undo-cancel"
+ },
+ "steps": [
+ "run the pinned specs gated on: IMPORT_CONSOLE_LIVE=1 pnpm exec playwright test e2e/import-console/import-console-undo.spec.ts (and the harness twin e2e/import-harness/import-undo.spec.ts); capture the suite output as the primary evidence",
+ "when the gate is available: POST /api/v1/data/:object/import/jobs with a marker-tagged CSV; poll GET /api/v1/data/import/jobs/:jobId until terminal; GET /api/v1/data/import/jobs/:jobId/results for per-row results",
+ "count marker rows via a filtered GET before and after the import; then POST /api/v1/data/import/jobs/:jobId/undo and re-count",
+ "GET /api/v1/data/import/jobs and confirm the job list distinguishes undoable/non-undoable and reverted state ({ jobId, undoable, revertedAt, createdAt }) so the fresh job is findable",
+ "start a fresh import and POST /api/v1/data/import/jobs/:jobId/cancel mid-job; inspect the row set for coherence"
+ ],
+ "acceptance": [
+ {
+ "clause": "an async import creates an UNDOABLE job that reaches a terminal state and exposes per-row results",
+ "oracle": "test",
+ "verify": "the job list shows { jobId, undoable:true, revertedAt:null } and GET /import/jobs/:jobId/results returns per-row outcomes (pinned by import-console-undo.spec.ts / import-undo.spec.ts under their gate)",
+ "evidence": "the gated spec output + the jobs/results reads"
+ },
+ {
+ "clause": "undo removes EXACTLY the imported rows — a marker filter count returns to its pre-import value, with no collateral deletion of pre-existing rows",
+ "oracle": "api",
+ "verify": "filtered GET marker count: pre-import == post-undo, and post-import == pre-import + imported count (the specs assert record counts at the backend on both sides)",
+ "evidence": "the three marker counts (pre / post-import / post-undo)"
+ },
+ {
+ "clause": "cancel mid-job yields a COHERENT partial: whole rows committed or none, never a half-written row (partial row / dangling FK)",
+ "oracle": "api",
+ "verify": "after cancel, the committed rows are complete records; no torn row exists; the job reports the cancelled/partial state",
+ "evidence": "the post-cancel row read + job state"
+ },
+ {
+ "clause": "the job list distinguishes undoable/non-undoable and reverted state so a run can find the fresh undoable job it created",
+ "oracle": "api",
+ "verify": "GET /api/v1/data/import/jobs returns entries with { jobId, undoable, revertedAt, createdAt } (the specs filter on j.undoable && !j.revertedAt)",
+ "evidence": "the jobs list read"
+ }
+ ],
+ "negative": [
+ "an undo that deletes MORE than the imported rows (or leaves some behind) is a FAIL — exactly the imported set, no more, no less (the async threshold vs undo-capture mismatch is the exact bug the specs exist for)",
+ "a cancel that leaves half-written rows (partial row, dangling FK) is a FAIL — coherent partial only",
+ "reporting the item PASS off a skipped (gated-out) spec is a FAIL — a skip is blocked(fixture), never a green tick"
+ ],
+ "traps": [
+ "seed-data-thin",
+ "dispatcher-vs-hono-route"
+ ],
+ "automated": {
+ "kind": "e2e",
+ "ref": "objectui: e2e/import-console/import-console-undo.spec.ts, e2e/import-harness/import-undo.spec.ts (both self-gate)"
+ },
+ "source": [
+ "framework: packages/rest/src/rest-route-ledger.ts:124-129 (POST /data/:object/import/jobs; /import/jobs/:jobId/{cancel,undo,results}; GET /import/jobs[/:jobId])",
+ "objectui: e2e/import-console/import-console-undo.spec.ts, e2e/import-harness/import-undo.spec.ts",
+ "PENDING-GAPS §C import-job-undo-cancel"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "initial — deep-test async import jobs (terminal + per-row results, exact-set undo, coherent cancel); blocked(fixture) on the IMPORT_CONSOLE_LIVE / harness gate, pinned to the self-gating specs",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.object-hook-lifecycle",
+ "title": "Object lifecycle hooks fire on the write path with the right timing, gate, async and error semantics — driven over /api/v1/data/*, oracled by the record effect and the log line",
+ "since": "v15",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "mixed",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "the four seeded showcase hooks (examples/app-showcase/src/data/hooks/index.ts, bound via defineStack({ hooks: allHooks })): showcase_normalize_task_title (showcase_task, events ['beforeInsert','beforeUpdate'], trims title, priority 50, onError:'abort'); showcase_stamp_inquiry_defaults (showcase_inquiry, beforeInsert, stamps status='new'/source='web', onError:'abort'); showcase_audit_task_completion (showcase_task, afterUpdate, condition previous.done!=true && record.done==true, async, retryPolicy {maxRetries:3,backoffMs:1000}, capabilities:['log'], onError:'log', priority 90); showcase_warn_over_budget (showcase_project, afterUpdate, condition record.spent!=null && record.budget!=null && record.spent>record.budget, async, capabilities:['log'], onError:'log')",
+ "showcase_task (title required text + done boolean, defaultValue false), showcase_inquiry (status select whose 'new' option is default:true + source plain text with NO default), showcase_project (budget/spent currency + the spent_within_budget script rule that REJECTS spent > budget*1.2)",
+ "seeded rows: not-done task 'Build homepage' (done:false) for the transition PATCH; already-done tasks 'Audit current IA' and 'App wireframes' (done:true) for the non-transition proof; projects 'Data Platform' (budget 600000 / spent 420000) and 'Website Relaunch' (budget 150000 / spent 60000)",
+ "server log capture — the async audit/warn bodies call ctx.log.info/warn, routed to the engine logger (packages/runtime/src/sandbox/body-runner.ts:321 log: engineCtx.logger); AND the ability to register a scratch hook (throwing / ordered) for the variants the four fixtures cannot observe",
+ "a real running server: hooks fire in the ObjectQL engine on the live /api/v1/data/* route, never on a simulated dispatch"
+ ],
+ "knownGaps": [
+ "afterInsert and afterDelete have NO fixture hook — the four seeded hooks cover beforeInsert, beforeUpdate and afterUpdate only. Exercise these two events with a scratch log hook or record the variant knownGap; do not fake coverage.",
+ "beforeDelete has NO seeded showcase hook — the abort-blocks-delete contract is pinned only by the dogfood hef_ref_guard fixture (packages/qa/dogfood/test/hook-error-format.dogfood.test.ts), a test-only stack, not the showcase app; drive beforeDelete via a scratch hook or cite that pin.",
+ "priority ordering (lowest-first on the SAME object+event) is not fixture-covered: no two showcase hooks share an object+event (normalize is showcase_task/before*, audit is showcase_task/afterUpdate — different events), so ordering needs a scratch pair of hooks on one object+event with distinct priorities.",
+ "onError:'abort' ROLLBACK and onError:'log' SUPPRESSION are only observable with a hook that THROWS — the four fixtures never throw (their trim/stamp/log bodies always succeed). The abort-rollback, log-tolerate and fail-closed-condition clauses each require a scratch throwing hook (or the cited dogfood abort pin).",
+ "the async audit/warn lines land AFTER the HTTP write resolves (fire-and-forget runs once the engine has moved on) — a log read taken before the async body executes shows no line; settle before judging any no-fire."
+ ]
+ },
+ "variants": [
+ "beforeInsert — FIXTURE: showcase_stamp_inquiry_defaults (stamps status/source on a new inquiry) and showcase_normalize_task_title (also trims on insert)",
+ "beforeUpdate — FIXTURE: showcase_normalize_task_title (trims title on every task update)",
+ "afterUpdate — FIXTURE: showcase_audit_task_completion (task done-transition audit) and showcase_warn_over_budget (project over-budget warn)",
+ "afterInsert — SCRATCH/knownGap: no showcase hook targets afterInsert; add a scratch afterInsert log hook or record knownGap",
+ "beforeDelete — SCRATCH/knownGap: no seeded showcase hook; the abort-blocks-delete shape is pinned only by the dogfood hef_ref_guard fixture (hook-error-format.dogfood.test.ts)",
+ "afterDelete — knownGap: no showcase hook and no dogfood pin; add a scratch afterDelete log hook to exercise",
+ "multi-event targeting — FIXTURE: showcase_normalize_task_title lists events ['beforeInsert','beforeUpdate'] — ONE hook fires on two write kinds (hook-binder registers per-event × per-object)",
+ "CEL condition — two-root transition semantics (#4770/#4784) — FIXTURE: showcase_audit_task_completion `previous.done != true && record.done == true`; since #4770 `record` is the record's STATE (stored ⊕ payload, total over declared fields), so `record.done == true` alone is true on every edit of an already-done row — `previous` is what makes 'just became done' expressible",
+ "CEL condition — total-record `!= null` guard, NOT has() (#4770) — FIXTURE: showcase_warn_over_budget `record.spent != null && record.budget != null && ...`; because `record` is made total over declared fields, `has(record.spent)` is uniformly TRUE even when the field holds null, so only `!= null` keeps `null > null` (which CEL has no overload for) from aborting the expression — an abort #4775 would turn into a REJECTED write",
+ "async fire-and-forget (after* ONLY) — FIXTURE: showcase_audit_task_completion + showcase_warn_over_budget (async:true); the wrapper ignores async on before* events (hook-wrappers.ts: fireAndForget = Boolean(meta.async) && isAfterEvent)",
+ "retryPolicy {maxRetries,backoffMs} — FIXTURE declares it: showcase_audit_task_completion {maxRetries:3, backoffMs:1000}; observing an actual RETRY needs a transiently-failing scratch body",
+ "capabilities:['log'] (L2 sandbox log capability) — FIXTURE: audit + warn bodies call ctx.log.info/warn wired to the engine logger (body-runner.ts:321)",
+ "onError:'abort' (rollback) — FIXTURE declares it on showcase_normalize_task_title / showcase_stamp_inquiry_defaults; observing the ROLLBACK needs a THROWING before* scratch hook (the fixtures never throw)",
+ "onError:'log' (tolerate + continue) — FIXTURE declares it on showcase_audit_task_completion / showcase_warn_over_budget; observing the SUPPRESSION needs a throwing async scratch (an async failure never rolls the triggering write back regardless)",
+ "priority ordering (lowest-first, same object+event) — SCRATCH/knownGap: no two showcase hooks share object+event, so register two scratch hooks on one object+event with priorities e.g. 10 and 90 and read the execution order from the log",
+ "condition fails CLOSED (#4775) — SCRATCH: an unevaluable/uncompilable condition ABORTS the operation and is NOT softened by onError:'log' nor fire-and-forgotten (the gate runs OUTSIDE both); add a scratch hook with a broken condition (unit-pinned in hook-wrappers.ts)"
+ ],
+ "steps": [
+ "boot showcase isolated; sign in as seeded admin; confirm the four hooks actually registered before asserting any no-fire — GET /api/v1/meta/types/hook (or the boot log) lists showcase_normalize_task_title / showcase_stamp_inquiry_defaults / showcase_audit_task_completion / showcase_warn_over_budget (a hook that never bound fakes every no-fire — seed-data-thin)",
+ "stamp defaults (beforeInsert): POST /api/v1/data/showcase_inquiry {name:'os-qa-'} OMITTING status AND source; re-read via GET and confirm source == 'web' — the hook is the SOLE producer of that value (the source field carries no default), so a correct read proves the hook fired; status == 'new' corroborates but is NOT hook-attributable alone (the 'new' status option is default:true)",
+ "title trim (beforeInsert + beforeUpdate): POST /api/v1/data/showcase_task with title ' os-qa- ' (leading/trailing spaces) and valid required fields; re-read → title == 'os-qa-' (insert trim); then PATCH the same row's title to another padded value and re-read → trimmed again (update trim); ONE multi-event hook covered both",
+ "completion audit — FIRES (transition): PATCH /api/v1/data/showcase_task/ {done:true} (previous done:false); after the HTTP response resolves, settle briefly (the audit is async fire-and-forget, it runs after the write returns), then read the server log for 'task completed: Build homepage'",
+ "completion audit — DOES NOT FIRE (two-root proof): PATCH an already-done task (, done:true) changing ONLY priority (medium→high), leaving done untouched; settle; confirm NO new 'task completed: App wireframes' line — previous.done == true makes the transition condition false even though record.done == true",
+ "over-budget warn — FIRES: PATCH /api/v1/data/showcase_project/ {spent:650000} (budget 600000 → over budget, but ≤ 720000 so the spent_within_budget rule still permits the write); settle; read the log for 'project over budget: Data Platform (650000 / 600000)'",
+ "over-budget warn — condition gate (under budget): PATCH 'Website Relaunch' {spent:70000} (still under its 150000 budget); settle; confirm NO warn line (record.spent > record.budget is false) — the same partial write proves `record` merges the stored budget it never sent",
+ "abort rollback (scratch): register a scratch beforeInsert hook whose body throws, onError:'abort', on a scratch object (or showcase_task); POST a row; capture the refusal and confirm a filtered GET count stays 0 (the write rolled back, no orphan) — cross-check the dogfood hef_ref_guard beforeDelete pin",
+ "onError:'log' tolerate (scratch): register a scratch async afterUpdate hook whose body throws, onError:'log'; trigger it with a real update; confirm the triggering write STILL landed (re-read shows the change) and the server logged '[hook] async handler error (fire-and-forget)' — a suppressed failure, not a rollback",
+ "priority ordering (scratch): register two scratch hooks on ONE object+event with priorities 10 and 90, each logging its own name; issue one write; confirm the log order is the priority-10 hook THEN the priority-90 hook (lower runs first)"
+ ],
+ "acceptance": [
+ {
+ "clause": "beforeInsert stamps server-controlled defaults: an inquiry POSTed with NO source reads back source == 'web' — the hook is the sole producer of that value (the field has no default), so a correct read PROVES the hook ran; status == 'new' corroborates but is not hook-attributable alone (the 'new' option is default:true)",
+ "oracle": "api",
+ "verify": "POST /api/v1/data/showcase_inquiry omitting source/status → GET the created row shows source 'web' (and status 'new'); pinned for the anonymous public-form path by packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts",
+ "evidence": "the POST payload + the re-read JSON"
+ },
+ {
+ "clause": "one multi-event hook trims on BOTH write kinds: a padded title ' X ' reads back 'X' after the create (beforeInsert) and again after an update (beforeUpdate) — events:['beforeInsert','beforeUpdate'] on a single hook",
+ "oracle": "api",
+ "verify": "re-reads after the padded POST and after the padded PATCH both return the trimmed title",
+ "evidence": "the two re-reads + the two write payloads"
+ },
+ {
+ "clause": "the afterUpdate audit fires on the COMPLETING update: PATCH done:false→true emits 'task completed: ' through the ['log'] capability — the async, retry-policied, priority-90 hook",
+ "oracle": "log",
+ "verify": "after settling for the fire-and-forget async body, the server log carries the line naming the task just completed",
+ "evidence": "the log excerpt + the triggering PATCH"
+ },
+ {
+ "clause": "the audit does NOT fire on a NON-transition edit of an already-done task — previous.done != true is false though record.done == true; this is the two-root transition semantics (#4770/#4784), and a fire here would prove the condition collapsed to a bare `record.done == true` state test",
+ "oracle": "log",
+ "verify": "PATCH only priority on a done task; a before/after diff of the server log shows NO new 'task completed' line for it",
+ "evidence": "the before/after log diff around the non-transition PATCH"
+ },
+ {
+ "clause": "the afterUpdate over-budget warn fires when record.spent > record.budget: PATCH spent above budget (but ≤ 120%, so the spent_within_budget rule permits the write) logs 'project over budget: ( / )'",
+ "oracle": "log",
+ "verify": "the server log carries the warn naming the project and the two numbers after the over-budget PATCH settles",
+ "evidence": "the log excerpt + the PATCH"
+ },
+ {
+ "clause": "the condition's `!= null` guard proves `record` is stored ⊕ payload (total over declared fields), NOT the bare patch: an over-budget PATCH that touches ONLY spent still reads budget from the stored row and fires; an under-budget PATCH emits no warn",
+ "oracle": "log",
+ "verify": "over-budget spent-only PATCH → warn line; under-budget PATCH → no line; the fire from a partial write is the proof budget was merged from storage",
+ "evidence": "both log states keyed to the two PATCHes"
+ },
+ {
+ "clause": "onError:'abort' on a THROWING before* hook rolls the write back: the POST is refused and a filtered GET count stays 0 — no orphan row lands",
+ "oracle": "api",
+ "verify": "scratch throwing beforeInsert; POST → error envelope; before/after filtered count both 0 (mirrors the dogfood hef_ref_guard beforeDelete → REST error-body pin in hook-error-format.dogfood.test.ts)",
+ "evidence": "the refusal response + the before/after counts"
+ },
+ {
+ "clause": "onError:'log' on a THROWING async afterUpdate hook does NOT roll the triggering write back: the PATCH persists (re-read shows the change) and the failure is only logged — a fire-and-forget failure can never un-commit a write the engine already resolved",
+ "oracle": "api",
+ "verify": "scratch throwing async hook; the PATCH's re-read shows the new value AND the server log carries '[hook] async handler error (fire-and-forget)'",
+ "evidence": "the persisted re-read + the suppressed-error log line"
+ },
+ {
+ "clause": "a condition that cannot be evaluated FAILS CLOSED (#4775): the write is ABORTED, not silently skipped — and it is NOT softened by onError:'log' nor fire-and-forgotten, because the condition gate runs OUTSIDE the async/retry/onError wrappers",
+ "oracle": "api",
+ "verify": "scratch hook with a broken/uncompilable condition on an object; a write to that object returns a HookConditionError-class refusal and the row does not land (hook-wrappers.ts: conditionFn throws outside runWithErrorPolicy)",
+ "evidence": "the refusal + a post-attempt count showing no row"
+ },
+ {
+ "clause": "two hooks on the SAME object+event run lowest-priority-first: scratch hooks at priority 10 and 90 log in that order on one write (hook-binder passes priority to engine.registerHook; the engine orders by it, lower first)",
+ "oracle": "log",
+ "verify": "the log shows the priority-10 hook's line before the priority-90 hook's line for a single triggering write",
+ "evidence": "the two ordered log lines"
+ }
+ ],
+ "negative": [
+ "a beforeInsert (or beforeDelete) hook that THROWS under onError:'abort' but leaves the row written (filtered count > 0) is a FAIL — abort must roll the WHOLE write back (the hef_ref_guard dogfood pin is the reference shape)",
+ "an async onError:'log' hook failure that ROLLS BACK the triggering write is a FAIL — fire-and-forget runs after the write resolved and can only be logged, never un-commit it",
+ "a SILENT no-fire on a real done:false→true transition is a FAIL — the audit MUST emit; if it truly did not, first rule out that the hooks registered (meta/types/hook) and that the async line was given time to settle before filing (seed-data-thin / async-settle)",
+ "the audit firing on a NON-transition edit of an already-done task is a FAIL — it would prove the condition collapsed from the two-root transition to a bare `record.done == true` state test (the exact #4784 regression)",
+ "an unevaluable condition that SILENTLY SKIPS (letting a before* guard through, or dropping an after* audit) instead of aborting the operation is a FAIL — #4775 fails closed, loudly, and onError never sees the condition error"
+ ],
+ "traps": [
+ "dispatcher-vs-hono-route",
+ "seed-data-thin",
+ "stale-dist",
+ "wrong-persona"
+ ],
+ "automated": {
+ "kind": "dogfood",
+ "ref": "packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts (pins the beforeInsert stamp: status='new'/source='web' on an anonymous inquiry submit); packages/qa/dogfood/test/hook-error-format.dogfood.test.ts (pins the beforeDelete onError:'abort' throw → REST error body). Declarative-wrapper semantics (two-root condition, async, retry, onError, fail-closed) are unit-pinned in packages/objectql/src/hook-wrappers.ts + hook-binder.ts tests. The transition audit / over-budget warn / priority-ordering LOG oracles and the abort/log/priority SCRATCH variants are NOT yet dogfood-pinned — drive them by hand."
+ },
+ "source": [
+ "examples/app-showcase/src/data/hooks/index.ts (the four fixture hooks + allHooks export; header comments spell out the two-root #4784 transition and the != null / not has() #4770 rationale verbatim)",
+ "packages/spec/src/data/hook.zod.ts (HookSchema + HookEvent enum beforeFind/afterFind/beforeInsert/afterInsert/beforeUpdate/afterUpdate/beforeDelete/afterDelete; defineHook; async 'after* only'; onError default 'abort'; empty-target refusal #4001)",
+ "packages/objectql/src/hook-wrappers.ts (wrapDeclarativeHook wrapping order condition→async→retry→timeout→onError; pickRecordPayload #4770 record = stored ⊕ payload total over declared fields; pickPreviousPayload #4784 previous binding; HookConditionError #4775 fail-closed, raised OUTSIDE onError; fireAndForget = async && isAfterEvent)",
+ "packages/objectql/src/hook-binder.ts (bindHooksToEngine: per-event × per-object engine.registerHook with priority; unresolved-body / empty-target skips)",
+ "packages/runtime/src/sandbox/body-runner.ts:321 (log: engineCtx.logger — the ['log'] capability routing that makes the audit/warn lines an observable log oracle)",
+ "packages/spec/liveness/hook.json (object/events/body/priority/async/condition/retryPolicy/timeout/onError all 'live'; label/description 'dead' but kept as docs)",
+ "examples/app-showcase/src/data/objects/{task,inquiry,project}.object.ts (task.done boolean + task.title required; inquiry.status select['new' default:true]/source text no-default; project.budget/spent currency + spent_within_budget rule rejecting spent > budget*1.2)",
+ "examples/app-showcase/src/data/seed/index.ts (done tasks 'Audit current IA'/'App wireframes'; not-done 'Build homepage'; projects 'Data Platform' 600000/420000 and 'Website Relaunch' 150000/60000)",
+ "packages/qa/dogfood/test/showcase-public-form.dogfood.test.ts + hook-error-format.dogfood.test.ts (the two existing dogfood pins this item cites)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "initial — the hook coverage.json waiver was STALE (showcase ships 4 observable hooks); authored a lifecycle+condition+async+onError+priority item",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.field-type-constraints",
+ "title": "Per-type CONSTRAINT enforcement on the write path (not just widget render): length/range/option/reference/computed limits, and where the platform deliberately does NOT enforce",
+ "since": "v15",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "mixed",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_field_zoo (examples/app-showcase/src/data/objects/field-zoo.object.ts): name text maxLength 200; f_number min 0 max 1000; f_currency scale 2 currencyConfig{precision 2} min 0; f_percent min 0 max 100; f_select {low,medium,high}; f_multiselect {red,green,blue}; f_lookup→showcase_account; f_master_detail→showcase_project; f_tree→showcase_category; f_autonumber (default counter, NO autonumberFormat); f_formula = f_number*f_percent/100",
+ "showcase_invoice (invoice.object.ts): status select {draft,sent,paid,void} required; account lookup with lookupFilters status!=churned; total summary(sum showcase_invoice_line.amount) — the summary carrier the zoo lacks"
+ ],
+ "knownGaps": [
+ "precision/scale is DECLARED but NOT enforced on the write path — record-validator.ts validateOne's number branch checks only min/max/finite, no runtime reads field.scale/precision for rounding (grep clean across objectql/rest/runtime), and the SQL driver stores number/currency/percent as table.float (REAL affinity), not DECIMAL(p,s) (sql-driver.ts createColumn ~7354-7368). A currency value with more decimals than `scale` is stored verbatim, unrounded. Record it as a gap; do NOT tick 'scale enforced'.",
+ "autonumberFormat TOKEN expansion ({0000}/{YYYY}/{MM}/{DD}/{YYYYMMDD}/{field}, per-rendered-prefix scope+reset) has NO showcase fixture — no showcase object declares autonumberFormat (f_autonumber uses the default bare 4-width global counter). Token expansion is pinned instead by packages/spec/src/data/autonumber-format.test.ts + packages/drivers/driver-sql/src/sql-driver-autonumber.test.ts; only server-assignment + client-value-ignored is driveable on the zoo.",
+ "the #4441 reference gate is EXISTENCE-only and deliberately unscoped (engine.ts assertReferencesResolve) — an id that exists but lies outside the field's lookupFilters/dependsOn scope is accepted; scope enforcement is client-only (drilled in records-forms.cascading-multilevel-and-clear).",
+ "summary is a runtime-MAINTAINED cache, not a write-rejected readonly field — a caller-supplied initial total is a SUPPORTED import path and is kept, then self-heals to the child aggregate on the next child write (rule-validator.ts RUNTIME_OWNED_FIELD_TYPES note, #6014). Only `formula` is strictly read-only-on-write; do NOT assert 'summary rejects direct writes'."
+ ]
+ },
+ "variants": [
+ "text",
+ "textarea",
+ "email",
+ "url",
+ "phone",
+ "number",
+ "currency",
+ "percent",
+ "date",
+ "datetime",
+ "time",
+ "select",
+ "radio",
+ "multiselect",
+ "checkboxes",
+ "tags",
+ "lookup",
+ "master_detail",
+ "tree",
+ "autonumber",
+ "formula",
+ "summary"
+ ],
+ "steps": [
+ "boot showcase isolated; sign in as the seeded admin; capture an API token for direct /api/v1/data/* POST/PATCH/GET",
+ "STRING length: POST /api/v1/data/showcase_field_zoo with name = a 201-char string (maxLength 200) and capture the refusal; then POST a 200-char name and capture success — confirm the long value was NOT silently truncated-and-stored",
+ "NUMBER range: POST f_number = -1, then = 1001, then a non-number ('abc'); capture each refusal; POST f_number = 500 and re-read verbatim",
+ "SCALE (gap probe): POST f_currency = 1234.567 (declared scale 2); GET the row and record whether the stored value is rounded (expected: stored 1234.567 verbatim — NOT enforced)",
+ "SELECT server boundary: POST f_select = 'not-a-value' AND (second carrier) POST showcase_invoice status = 'archived'; capture both invalid_option refusals with their options[] echo; confirm neither row count grew",
+ "MULTISELECT shape: POST f_multiselect = 'red' (a scalar) and re-read as ['red'] (coerced to array); POST f_multiselect = {x:1} (a non-array object) → capture invalid_type; POST f_multiselect = ['red','purple'] → capture the per-element invalid_option naming 'purple'; POST ['red','blue'] → re-read as a set",
+ "REFERENCE: POST f_lookup = a fabricated account id → capture reference_not_found; POST f_lookup = a real seeded account id → re-read verbatim (note: an EXISTING but out-of-scope id is accepted — see cascading-multilevel-and-clear)",
+ "AUTONUMBER server-assigned: POST a row with f_autonumber = 'HACK-9999'; GET it and confirm the stored value is the engine's sequence number (client value dropped, #5503); PATCH f_autonumber on an existing row and confirm the change is dropped while the call returns success",
+ "FORMULA read-only: POST a row writing f_formula = 999 directly; GET and confirm f_formula equals f_number*f_percent/100 (the write was ignored); with f_number=42, f_percent=75 the read is 31.5",
+ "SUMMARY (nuance): create a showcase_invoice + lines; GET total and confirm it equals the line sum server-side; separately confirm a caller-supplied initial total on import is retained (maintained cache, not rejected) — do not treat summary as write-rejected"
+ ],
+ "acceptance": [
+ {
+ "clause": "text/textarea/email/url/phone length is a REJECT, not a truncation: a value longer than the field's maxLength returns 400 VALIDATION_FAILED with fields[] {field, code:'max_length', constraint:{maxLength, actual}} and stores NOTHING — the over-length value is never silently truncated to fit",
+ "oracle": "api",
+ "verify": "the 201-char name POST returns max_length naming maxLength 200 & actual 201; the row count does not grow; a 200-char name succeeds. Codes are the ADR-0114 FieldErrorCode catalog (packages/spec/src/api/errors.zod.ts); enforcement in packages/objectql/src/validation/record-validator.ts validateOne string branch",
+ "evidence": "both refusal/success responses + before/after row counts"
+ },
+ {
+ "clause": "number/currency/percent range is bounded both sides server-side: below `min` → code 'min_value' {min}; above `max` → 'max_value' {max}; a non-finite value → 'invalid_number'; an in-range value round-trips verbatim",
+ "oracle": "api",
+ "verify": "f_number (min 0 max 1000) rejects -1 (min_value) and 1001 (max_value) and 'abc' (invalid_number); 500 re-reads as 500. record-validator.ts validateOne number branch",
+ "evidence": "the three refusals + the in-range re-read"
+ },
+ {
+ "clause": "KNOWN GAP recorded, not ticked: `scale`/`precision` are NOT enforced on write — a currency value with more decimals than the declared scale is stored VERBATIM (float column, validator checks only min/max/finite). The run records the observed unrounded value as a gap and must NOT report 'scale enforced'",
+ "oracle": "api",
+ "verify": "POST f_currency 1234.567 (scale 2) succeeds and GET returns 1234.567 unrounded — corroborated by record-validator.ts (no scale rounding) and sql-driver.ts createColumn (table.float, not DECIMAL). A run that shows rounding-to-2 would be a NEW enforcement to file, not a pass here",
+ "evidence": "the write response + the unrounded re-read"
+ },
+ {
+ "clause": "closed-set types reject a non-declared option value SERVER-side on at least two carriers (not just the widget): f_select and showcase_invoice.status each refuse an out-of-set value with code 'invalid_option' and an options[] listing the allowed set; the row count does not grow",
+ "oracle": "api",
+ "verify": "direct POSTs f_select='not-a-value' and status='archived' both return invalid_option carrying options[]; record-validator.ts select/radio branch (allowed = optionValues(def.options))",
+ "evidence": "both refusal envelopes (with options[]) + before/after counts"
+ },
+ {
+ "clause": "multiselect storage/read shape is an ARRAY: a lone scalar is coerced to a 1-element array (normalizeMultiValueFields), a non-array object is rejected 'invalid_type', an element outside options is rejected 'invalid_option' echoing the offending element, and a valid array round-trips compared as a set",
+ "oracle": "api",
+ "verify": "f_multiselect: 'red'→['red']; {x:1}→invalid_type; ['red','purple']→invalid_option naming 'purple'; ['red','blue']→set-equal on re-read. record-validator.ts multi-value branch + normalizeMultiValueFields",
+ "evidence": "the four responses + re-reads"
+ },
+ {
+ "clause": "reference types (lookup/master_detail/tree/user) store the id verbatim and reject a DANGLING id with code 'reference_not_found' {target} for a non-system caller (#4441) — with the boundary that the gate is EXISTENCE-only: an id that exists but is out of lookupFilters/dependsOn scope is accepted (unscoped by design)",
+ "oracle": "api",
+ "verify": "a fabricated f_lookup id → reference_not_found; a real seeded account id → verbatim re-read; engine.ts assertReferencesResolve + referenceExists (isSystem/readonly/caller-supplied narrowing). Pinned by packages/objectql/src/engine-lookup-referential-integrity.test.ts",
+ "evidence": "the dangling refusal + the verbatim re-read + a note that an out-of-scope existing id is accepted"
+ },
+ {
+ "clause": "autonumber is server-assigned and a client value is IGNORED on both write paths: a POSTed f_autonumber is replaced by the engine sequence value (stripRuntimeOwnedFields, #5503) and a PATCH of it is dropped — the call returns success while the column holds the generated number, never the forged one",
+ "oracle": "api",
+ "verify": "POST f_autonumber='HACK-9999' re-reads as a sequence number, not 'HACK-9999'; PATCH is a no-op on the field. rule-validator.ts RUNTIME_OWNED_FIELD_TYPES=['autonumber'] + stripRuntimeOwnedFields/stripReadonlyFields. Pinned by packages/objectql/src/engine-autonumber-runtime-owned.test.ts",
+ "evidence": "the create re-read + the PATCH no-op re-read"
+ },
+ {
+ "clause": "formula is read-only/computed: a direct write to f_formula is ignored and the read equals f_number*f_percent/100 (42*75/100 = 31.5); summary is a runtime-MAINTAINED cache (invoice.total = Σ line.amount server-side) and is NOT write-rejected — a caller's initial total is a supported import path that self-heals on the next child write",
+ "oracle": "api",
+ "verify": "GET f_formula after a direct write shows 31.5 (write ignored); GET showcase_invoice.total equals the seeded line sum (objectui e2e/live/summary-rollup.spec.ts). The formula/summary distinction: rule-validator.ts note (formula computed-on-read; summary maintained cache, deliberately NOT in RUNTIME_OWNED)",
+ "evidence": "the formula re-read + the invoice total re-read"
+ }
+ ],
+ "negative": [
+ "a 201-char value silently TRUNCATED to 200 and stored with 200/201 is a FAIL — the contract is reject-not-truncate (max_length), and a truncating write corrupts the value with no signal",
+ "an out-of-set select/status value accepted with 200 is a FAIL — the record-validator, not the picker, is the boundary",
+ "a client-supplied f_autonumber persisted verbatim is a FAIL — a forged business identifier bypassing the sequence (#5503)",
+ "a currency value rounded to `scale` and reported as 'scale enforced' is a FALSE PASS — the platform does not round on write; the honest verdict is the recorded gap",
+ "a dangling lookup id accepted with 200 is a FAIL (#4441)"
+ ],
+ "traps": [
+ "hydration-race",
+ "automation-input",
+ "stale-console-bundle"
+ ],
+ "automated": {
+ "kind": "api",
+ "ref": "packages/qa/dogfood/test/field-zoo-roundtrip.dogfood.test.ts (+ field-zoo.matrix.ts vectors, field-zoo-value-shape.test.ts) for round-trip & masking; packages/objectql/src/engine-lookup-referential-integrity.test.ts for reference_not_found (#4441); packages/objectql/src/engine-autonumber-runtime-owned.test.ts for the autonumber strip (#5503)"
+ },
+ "source": [
+ "packages/objectql/src/validation/record-validator.ts (validateOne: max_length/min_length, min_value/max_value/invalid_number, invalid_option for select+multiselect, normalizeMultiValueFields; NO scale/precision rounding)",
+ "packages/spec/src/api/errors.zod.ts (FieldErrorCode catalog — required, max_length, min_value/max_value, invalid_option, reference_not_found, invalid_type … ADR-0114)",
+ "packages/spec/src/data/field.zod.ts (per-type constraints: maxLength/minLength, precision/scale/min/max, options value-vs-label, lookupFilters/dependsOn, autonumberFormat tokens, formula expression, summaryOperations)",
+ "packages/drivers/driver-sql/src/sql-driver.ts (createColumn: number/currency/percent → table.float, NOT DECIMAL; datetime → DATETIME(3)/timestamptz)",
+ "packages/objectql/src/engine.ts (assertReferencesResolve #4441 — existence-only, unscoped)",
+ "packages/objectql/src/validation/rule-validator.ts (RUNTIME_OWNED_FIELD_TYPES=['autonumber']; stripRuntimeOwnedFields/stripReadonlyFields; formula-vs-summary note #6014)",
+ "packages/spec/src/data/autonumber-format.ts (token grammar) + autonumber-format.test.ts",
+ "examples/app-showcase/src/data/objects/field-zoo.object.ts, invoice.object.ts (status/account/total carriers)"
+ ],
+ "history": [
+ {
+ "revision": 1,
+ "date": "2026-08-08",
+ "change": "initial — drills the field-type-matrix's shallow 'reject invalid' clause into per-type CONSTRAINT enforcement on the write path (length reject-not-truncate, numeric range, option server-boundary on two carriers, multiselect array shape, dangling-reference gate + its unscoped boundary, autonumber server-assignment, formula read-only) and records the deliberate non-enforcement gaps (scale/precision, autonumberFormat tokens, summary maintained-not-rejected)",
+ "ref": "claude/platform-test-checklist-ocwugl"
+ }
+ ]
+ },
+ {
+ "id": "records-forms.cascading-multilevel-and-clear",
+ "title": "Cascade clear-semantics and the server boundary: stale-child clear on parent change, the WRITTEN-value server gate, and where cascade scope is client-only",
+ "since": "v16",
+ "status": "active",
+ "revision": 1,
+ "priority": "P1",
+ "surface": "mixed",
+ "personas": [
+ "seeded admin (admin@objectos.ai / admin123)"
+ ],
+ "fixtures": {
+ "app": "showcase",
+ "requires": [
+ "showcase_cascade (examples/app-showcase/src/data/objects/cascading-select.object.ts): country → province, province dependsOn ['country'] with per-option visibleWhen (cn→zj/gd, us→ca/tx); sharingModel public_read_write",
+ "showcase_invoice.contact dependsOn ['account'] (invoice.object.ts) — the dependent LOOKUP twin; account carries lookupFilters status!=churned; seed spread Northwind/Contoso/Fabrikam (examples/app-showcase/src/data/seed/index.ts)"
+ ],
+ "knownGaps": [
+ "NO 3-level (grandparent→parent→child) dependsOn fixture exists in the showcase — the deepest chain is 2-level (country→province). The 'changing the grandparent clears/revalidates BOTH descendants' behavior cannot be driven; blocked on a fixture (a 3-level cascade object). The 2-level clear (clause 0) and the server WRITTEN-value gate (clause 1) are the driveable depth.",
+ "SELECT cascade server enforcement is WRITTEN-value-only: evaluateOptionVisibility re-checks a picked option only when that field is IN the write payload (rule-validator.ts: `!(name in data) continue`). A PATCH that changes ONLY the parent and omits the now-invalid child does NOT revalidate it server-side (clause 2) — server-side cascade integrity on the change-parent path depends entirely on the CLIENT clear. Recorded as a product gap.",
+ "LOOKUP cascade scope is NOT server-enforced at all: the #4441 reference gate (engine.ts assertReferencesResolve) is deliberately EXISTENCE-only/unscoped, so a directly-POSTed contact that belongs to a DIFFERENT account is accepted as long as the row exists (clause 4). dependsOn/lookupFilters is a picker-side narrowing; the only server reference guard is dangling→reference_not_found."
+ ]
+ },
+ "steps": [
+ "boot showcase isolated; sign in as the seeded admin; keep an API token for direct /api/v1/data/* writes",
+ "SELECT client clear: open a New Cascading Select (/_console/apps/com.example.showcase/showcase_cascade); pick country=cn, province=zj; screenshot; switch country=us; screenshot; enumerate province options (expect {California,Texas}) and confirm zj CLEARED and not carried into the us submit",
+ "SELECT server gate (written value): direct POST /api/v1/data/showcase_cascade {country:'cn', province:'ca'} → capture the refusal; then {country:'cn', province:'zj'} → capture success",
+ "SELECT stale-child EDGE (gap probe): create {country:'cn', province:'zj'}; PATCH only {country:'us'} (omit province); GET the row and record whether province='zj' (now invalid for us) is still stored and the PATCH returned 200 — expected: accepted, stale value kept (server does not revalidate an unwritten child)",
+ "LOOKUP twin client re-query + clear: on a New Invoice, choose account=Northwind and capture the contact picker's candidate request (scoped to Northwind); pick a Northwind contact; switch account=Contoso; capture the re-issued (Contoso-scoped) candidate request and confirm the Northwind contact CLEARED from the field",
+ "LOOKUP scope server boundary (gap probe): direct POST /api/v1/data/showcase_invoice with account=