diff --git a/.changeset/spec-membership-role-doc-comment-adr-0108.md b/.changeset/spec-membership-role-doc-comment-adr-0108.md new file mode 100644 index 0000000000..face9bf8eb --- /dev/null +++ b/.changeset/spec-membership-role-doc-comment-adr-0108.md @@ -0,0 +1,33 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): `MemberSchema.role` / `InvitationSchema.role` stop naming `guest` a membership role (#7740) + +Doc-comment and `.describe()` text only — no schema, no validation, no runtime +behaviour changes. + +Both doc-comments in `packages/spec/src/identity/organization.zod.ts` described +the membership role vocabulary as `'owner' | 'admin' | 'member' | 'guest'`, "can +be customized per application". Neither half is true any more. Under ADR-0108 the +vocabulary is **closed** and is `owner`, `admin`, `delegated_admin`, `member` +(`BUILTIN_MEMBERSHIP_ROLES` / `BUILTIN_MEMBERSHIP_ROLE_OPTIONS` in +`./membership-role.ts`, which is what `sys_member.role` and +`sys_invitation.role` register as their select options). Nothing widens the list +at boot, `guest` is refused at better-auth's role check with `ROLE_NOT_FOUND` +before any row is inserted, and a stack that needs another business role declares +a `position` — the routes +`packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts` already +pins. + +The text is worth correcting rather than leaving to rot: it is the most +reachable description of the field, and it demonstrably propagated — the +`identity-auth.org-membership-team-management` platform-checklist item named the +same wrong four roles, which is how the identity-auth QA run (#7663) found this. +Both are fixed in the same change. + +`role` stays typed `z.string()`: the wire shape mirrors better-auth's own column. +That is now said explicitly in the comment, so the loose type is not re-read as +evidence that the set is open. The reference page generated from these +`.describe()` strings (`content/docs/references/identity/organization.mdx`) is +regenerated to match. diff --git a/content/docs/references/identity/organization.mdx b/content/docs/references/identity/organization.mdx index 14ab8ffb93..8e6d79d063 100644 --- a/content/docs/references/identity/organization.mdx +++ b/content/docs/references/identity/organization.mdx @@ -37,7 +37,7 @@ const result = InvitationSchema.parse(data); | **id** | `string` | ✅ | Unique invitation identifier | | **organizationId** | `string` | ✅ | Organization ID | | **email** | `string` | ✅ | Invitee email address | -| **role** | `string` | ✅ | Role to assign upon acceptance | +| **role** | `string` | ✅ | Role to assign upon acceptance (owner, admin, delegated_admin, member — ADR-0108 closed vocabulary) | | **status** | `Enum<'pending' \| 'accepted' \| 'rejected' \| 'expired'>` | ✅ | Invitation status | | **expiresAt** | `string` | ✅ | Invitation expiry timestamp | | **inviterId** | `string` | ✅ | User ID of the inviter | @@ -68,7 +68,7 @@ const result = InvitationSchema.parse(data); | **id** | `string` | ✅ | Unique member identifier | | **organizationId** | `string` | ✅ | Organization ID | | **userId** | `string` | ✅ | User ID | -| **role** | `string` | ✅ | Member role (e.g., owner, admin, member, guest) | +| **role** | `string` | ✅ | Member role (owner, admin, delegated_admin, member — ADR-0108 closed vocabulary) | | **createdAt** | `string` | ✅ | Member creation timestamp | | **updatedAt** | `string` | ✅ | Last update timestamp | diff --git a/docs/qa/platform-checklist/README.md b/docs/qa/platform-checklist/README.md index 5ba6f2371b..7185c6297e 100644 --- a/docs/qa/platform-checklist/README.md +++ b/docs/qa/platform-checklist/README.md @@ -54,7 +54,11 @@ next-sequential numbers do. "fixtures": { // what the environment must provide — the #1 cause of "app": "showcase", // blocked runs in #3358 was missing fixtures, so they are "requires": ["…"], // declared up front, and known gaps are recorded, not - "knownGaps": ["…"] // rediscovered every sweep + "knownGaps": ["…"], // rediscovered every sweep + "provisioning": { // OPT INTO an area-level recipe (next section) instead of + "use": "qa-scratch-authz", // copying its call sequence into this item + "why": "which clauses it unblocks, and what they would score without it" + } }, "steps": ["…"], // how to exercise it "acceptance": [ // ★ the acceptance criteria — one clause per assertable fact @@ -88,6 +92,74 @@ Design notes: runs may satisfy it by executing that test and citing its output as evidence, instead of re-driving the browser. +### Area-level `fixtures` — one named provisioning recipe, many items + +When several items in an area need the *same* environment that stock seeds do not +provide, the recipe is written **once at the area level** and items **reference** it. Two +halves, both required — a recipe nobody references is dead text, and a reference to a +recipe that isn't there is a dangling pointer. The worked instance is `qa-scratch-authz` +in [`areas/attachments-storage.json`](./areas/attachments-storage.json) (#7716/#7670); +copy its shape rather than inventing a second one. + +**Half 1 — the recipe**, a keyed block beside the area's `area`/`title`/`items` keys: + +```jsonc +"fixtures": { // AREA level — a sibling of "items", not inside one + "$comment": "…", // what this block is, and the replay rule for runners + "qa-scratch-authz": { // the recipe KEY — what items name in `provisioning.use` + "title": "Scratch authz parents (qa_vault / qa_shared / qa_nofiles) + two member personas", + "why": "what is missing from stock seeds, and which clauses block(fixture) without it", + "provenance": "run #7635 (framework 92f26f75) → #7670", // where the recipe was proven + "app": "showcase", + "requires": ["capabilities/sessions the recipe itself needs before step 1"], + "sequence": [ // the calls, in order — replayable literally + { "step": 1, + "call": "POST /api/v1/packages", + "body": { "…": "…" }, // optional; omit for a non-body step + "expect": "what a correct response looks like — and the re-run/409 caveat", + "source": "framework file:line that grounds the call and its shape" } + ], + "teardown": "the one call (or the cheaper discard-the-DB path) that undoes it", + "knownGaps": ["where the recipe is known to be sharp — e.g. an SDK helper that drops ?package="] + } +} +``` + +**Half 2 — the reference**, on each item that needs it: + +```jsonc +"fixtures": { + "app": "showcase", + "requires": ["…"], // item-specific needs stay here + "provisioning": { + "use": "qa-scratch-authz", // must match a key in the AREA's fixtures block + "why": "which of THIS item's clauses the recipe unblocks, and what they'd score without it" + }, + "knownGaps": ["CLOSED by the qa-scratch-authz recipe (#7670): … ; fall back to only if …"] +} +``` + +Why this shape: + +- **Recipes are runtime-provisioned.** No repo file is touched and nothing is seeded, so + the only cleanup is the `teardown` line. That is what makes a recipe safe to replay on + a live boot — and why `requires` must name the capability the recipe itself needs + (e.g. a session holding `manage_metadata`) rather than assuming a bare admin session. +- **Every call cites framework source at `file:line`.** Replay them literally; if one + 4xxs, re-read the citation before assuming the recipe rotted. +- **`why` is the debt marker.** A recipe exists because stock fixtures cannot demonstrate + something — the same discipline as a coverage waiver. Landing the fixture in the + showcase seeds proper retires the recipe; until then `why` says what is missing and + which clauses would go `blocked(fixture)` without it. +- **Opting in does not delete the item's `knownGaps`** — it rewrites them as + *CLOSED-by-recipe*, naming any pinned fallback and asking the run to record **which** + of the two its verdict rests on. Deleting the gap loses the reason the recipe exists. + +The validator does **not** yet resolve `provisioning.use` against the area's `fixtures` +keys — that was deliberately deferred (option C on #7716's open question, tracked at +#7720), to be revisited if the recipe shape spreads to more areas. Until then a typo'd +`use` is caught by review, not by `check:platform-checklist`: copy the key, don't retype it. + ## Lifecycle — append, change, retire (never delete) - **Append** — add an item to its area file (or add a new area file). Pick an diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index cbdaf6c158..5768540e03 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -278,7 +278,7 @@ "title": "Invitation issuance honors role-scope gates: delegated_admin can invite members but cannot mint admins; a plain member cannot invite at all", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": ["delegated_admin org member", "plain member", "tenant admin (setup only)"], @@ -297,7 +297,7 @@ "as a plain member: verify the invite affordance is absent in the UI, then fire the invitation endpoint directly and capture the server refusal", "as the invited member address, verify the pending invitation is visible/actionable where the product surfaces it, and check its status vocabulary against the spec enum", "exercise cancel_invitation (or resend_invitation) on the pending row as the entitled persona and verify the status/state change via the API", - "read the delegable-scope surface that feeds the invite role picker: GET /api/v1/security/my-delegable-scope as the delegated_admin (strictly self-scoped, no target-user parameter — ADR-0090 D12 / ADR-0105 D8) and confirm the returned role set EXCLUDES admin-mintable roles; repeat as a plain member and confirm the scope is empty or the call is denied" + "read the delegable-scope surface: GET /api/v1/security/my-delegable-scope as the delegated_admin (strictly self-scoped, no target-user parameter — ADR-0090 D12 / ADR-0105 D8) and record the response shape; repeat as a plain member. NOTE (run #7663): this is a DIFFERENT axis from the invitation role — DelegableScope carries { isTenantAdmin, scopes, placeableBusinessUnitIds, assignablePositions } and has no field for a better-auth org invitation role, so do NOT score it as the role picker's allowlist" ], "acceptance": [ { @@ -337,16 +337,16 @@ "evidence": "the before/after reads" }, { - "clause": "the delegable-scope read is the picker's server truth: GET /api/v1/security/my-delegable-scope returns, for the delegated_admin, exactly the roles that principal may mint (admin-mintable roles absent) — so the UI cannot offer an admin invite it would then be refused for; a plain member's scope is empty or the call is denied", + "clause": "the delegable-scope read is self-scoped and stays on ITS OWN axis: GET /api/v1/security/my-delegable-scope answers for the caller only (no target-user parameter) and returns ObjectStack delegation scope — positions, permission sets, business-unit subtrees — never a better-auth org invitation role. It may therefore UNDER-report relative to what the caller can invite (an empty scope alongside a delegate who can still mint a member invitation is CORRECT), and it must never grow a role the caller cannot mint", "oracle": "api", - "verify": "the delegated_admin response's role list contains member but NOT admin; the plain member's response is empty/denied — cross-checked against the admin-role refusal proven above (client method security.describeDelegableScope, rest-route-ledger.ts)", + "verify": "the two /security/my-delegable-scope responses match the DelegableScope shape { isTenantAdmin, scopes[{assignablePermissionSets, businessUnitIds}], placeableBusinessUnitIds, assignablePositions } (packages/spec/src/contracts/security-service.ts:92-105) — assert on those keys, NOT on a role list; the plain member's scope is empty or the call is denied. Score the invite-role gate from clauses 1-4 (the endpoint), not from this read (client method security.describeDelegableScope, rest-route-ledger.ts)", "evidence": "the two /security/my-delegable-scope responses" } ], "negative": [ "an admin-role invitation that returns success, or that leaves ANY row behind, is a FAIL of privilege-escalation severity — file immediately, P0-verify per RUNNER rule 7", "UI-only enforcement (affordance hidden but the forged request succeeds) is a FAIL — the server is the authority (ADR-0057 D10)", - "my-delegable-scope returning admin (or any role the caller cannot actually mint) is a FAIL — the picker would offer an invite the endpoint then refuses, and worse, a client that trusts the scope could try to mint it" + "my-delegable-scope OVER-reporting is a FAIL — a position, permission set or BU subtree the caller cannot actually delegate, or (were the shape ever to grow one) a role the caller cannot mint: a client that trusts the scope would offer what the endpoint then refuses. UNDER-reporting is NOT a FAIL and must not be filed as one: the endpoint answering an empty scope while the delegate can still mint a member invitation is the safe direction and the expected state on stock fixtures (run #7663 — DelegableScope has no invitation-role field at all)" ], "automated": { "kind": "e2e", "ref": "packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts" }, "traps": ["wrong-persona", "dispatcher-vs-hono-route"], @@ -354,11 +354,13 @@ "packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts (ADR-0105 D8 / #3697; the escalation chain the role cap blocks)", "packages/spec/src/identity/organization.zod.ts (InvitationSchema, InvitationStatus enum)", "packages/rest/src/rest-route-ledger.ts (GET /api/v1/security/my-delegable-scope — security.describeDelegableScope, ADR-0090 D12 / ADR-0105 D8, self-scoped read half of the delegated-admin gate)", + "packages/spec/src/contracts/security-service.ts:92-105 (DelegableScope: isTenantAdmin, scopes, placeableBusinessUnitIds, assignablePositions — no invitation-role field, which is why the scope read cannot be the invite picker's allowlist)", "packages/spec/src/kernel/public-auth-features.ts (organization feature gates sys_invitation invite/cancel/resend actions)" ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "new item: invitation scope gates and lifecycle, pinned to the delegated-admin-invite dogfood test", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-08-08", "change": "added GET /api/v1/security/my-delegable-scope clause (delegated_admin scope excludes admin-mintable roles; plain member empty/denied) — the read half that feeds the invite role picker (PENDING-GAPS §D)", "ref": "claude/platform-test-checklist-ocwugl" } + { "revision": 2, "date": "2026-08-08", "change": "added GET /api/v1/security/my-delegable-scope clause (delegated_admin scope excludes admin-mintable roles; plain member empty/denied) — the read half that feeds the invite role picker (PENDING-GAPS §D)", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 3, "date": "2026-08-11", "change": "CORRECTION from run #7663: the delegable-scope clause conflated two axes. DelegableScope models ObjectStack positions / permission sets / business units (security-service.ts:92-105) and has NO field that could carry a better-auth org invitation role, so 'the returned role set contains member but not admin' was unassertable — the run watched a delegate mint a member invitation while the endpoint reported an empty assignable set. Rewrote the clause to assert the DelegableScope shape and the self-scoping, moved the invite-role gate onto clauses 1-4 (where the endpoint is the authority), and split the negative: OVER-reporting is the FAIL, UNDER-reporting is the safe direction and must not be filed", "ref": "#7740" } ] }, { @@ -366,7 +368,7 @@ "title": "Admin user-lifecycle operations (ban/unban, set-password, impersonate, create/set-role/remove, revoke-sessions) enforce, persist, and stay closed to non-admins", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": ["platform admin", "target user", "non-admin forger"], @@ -385,7 +387,7 @@ "attempt to sign in as the banned user (POST /api/v1/auth/sign-in/email) and capture the refusal", "unban, then verify the same sign-in now succeeds", "set the target's password via the admin set-password (out-of-band recovery); verify the NEW password signs in and the OLD one is refused", - "sign the target in to establish a LIVE session, then as admin POST /api/v1/auth/admin/revoke-user-sessions for that target; the target's very next authed request (get-session) must 401 mid-flight — the kill is immediate, not deferred to expiry", + "sign the target in to establish a LIVE session, then as admin POST /api/v1/auth/admin/revoke-user-sessions for that target; the target's very next PROTECTED authed request (e.g. GET /api/v1/data/) must be refused mid-flight — the kill is immediate, not deferred to expiry. Do NOT score this off get-session's status code: better-auth answers get-session with HTTP 200 and a JSON null body when the session is gone (session-of-record.test.ts:165), so a status-only assertion passes against a fully revoked session", "change the target's role via POST /api/v1/auth/admin/set-role and prove the change bites: an operation the new role gates flips outcome (e.g. promote → an admin-only read now 2xx; demote → it now 403)", "impersonate the target from the admin surface; verify via the API that the impersonation session carries impersonated_by, and screenshot the console's impersonation state; stop impersonating and verify the admin's own session is restored", "POST /api/v1/auth/admin/remove-user for a throwaway user that OWNS at least one showcase row (task/note), then read that owned row back: its owner_id is cleared to null (engine referential-integrity FK clear), the row itself survives, and the owner-anchor transfer guard did NOT veto the cascade (#3023/#3048)", @@ -418,10 +420,10 @@ "evidence": "the two gated-request responses bracketing the set-role" }, { - "clause": "revoke-user-sessions kills the target's LIVE session mid-flight: a session that answered get-session a moment earlier now 401s immediately after the admin revoke — not at token expiry", + "clause": "revoke-user-sessions kills the target's LIVE session mid-flight: a PROTECTED authed request that succeeded a moment earlier is refused immediately after the admin revoke — not at token expiry", "oracle": "api", - "verify": "get-session as the target: 2xx before the admin revoke, 401 on the very next call after it", - "evidence": "the before/after get-session pair" + "verify": "ORACLE = a protected authed request as the target (a data read the target was entitled to), 2xx before the revoke and refused on the very next call after it. get-session is NOT the oracle for this clause: better-auth's no-session convention is HTTP 200 with a JSON null body, so a 401 expectation misdescribes a correct implementation and a status-only assertion passes against a revoked session (packages/plugins/plugin-auth/src/session-of-record.test.ts:165). If get-session is captured at all, read its BODY (user null) as corroboration only", + "evidence": "the before/after protected-request pair (plus the get-session body, if captured)" }, { "clause": "engine cascade exemption (§A5): removing a user who OWNS rows clears owner_id to null on those rows via the engine's referential-integrity FK clear — the owner-anchor transfer guard does NOT veto this system-context cascade write (it rides a server-DERIVED marker, __referentialFieldClear, that cannot be forged from a request), and the owned row survives with owner_id null rather than the delete aborting", @@ -457,7 +459,7 @@ "negative": [ "a non-admin forged admin operation succeeding is a FAIL of the highest severity — apply RUNNER rule 7 (independent re-derivation) before acting on it", "a ban that hides the user in the UI while their sign-in still works is a FAIL — the sign-in refusal is the enforcement, not the list filter", - "revoke-user-sessions that only stops NEW logins while the existing live session keeps answering is a FAIL — the contract is an immediate kill", + "revoke-user-sessions that only stops NEW logins while the existing live session keeps answering a PROTECTED request is a FAIL — the contract is an immediate kill. A get-session that answers 200 after the revoke is NOT that failure (better-auth's no-session convention is 200-with-null-body); filing it as one is the false positive run #7663 corrected — read the body, or better, re-drive a protected request", "remove-user aborting because the owner-anchor guard vetoed the owner_id-null cascade (instead of exempting the engine FK clear) is the #3023 regression returned — FAIL; equally, a create-user that applies the GENERATED password when an explicit one was supplied is the #3031 failure — FAIL" ], "automated": { "kind": "e2e", "ref": "packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts" }, @@ -468,11 +470,13 @@ "packages/plugins/plugin-auth/src/admin-user-endpoints.ts (create-user resolvePassword: explicit password wins over generatePassword — #3031/#3033; leaves sys_user + credential sys_account)", "packages/plugins/plugin-security/src/security-plugin.ts (§A5 #3023 EXEMPTION: __referentialFieldClear owner_id-null cascade rides a server-derived context, the owner-anchor guard must not veto it) + security-plugin.test.ts '[#3023] … engine referential FK clear … is exempt'", "packages/spec/src/kernel/public-auth-features.ts (admin flag gates the sys_user lifecycle actions; SCIM forces it on — ADR-0071)", - "packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts" + "packages/qa/dogfood/test/admin-identity-audit-trail.dogfood.test.ts", + "packages/plugins/plugin-auth/src/session-of-record.test.ts:165 (better-auth answers /get-session with HTTP 200 + a JSON null body when the session is gone — NOT 401; a status-only assertion would pass against a fully revoked session)" ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "new item: admin lifecycle operations with persistence, enforcement, attribution and both-sides gate checks", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-08-08", "change": "added admin/list-users, create-user (explicit-password-wins §E12 #3031/#3033, signs in), set-role (flips gate outcomes), remove-user, revoke-user-sessions (kills live session mid-flight), each non-admin-refused; plus the §A5 engine cascade exemption clause (delete sys_user → owned rows' owner_id set_null; owner-anchor guard does not veto the system-context cascade, #3023/#3048) (PENDING-GAPS §D + §G)", "ref": "claude/platform-test-checklist-ocwugl" } + { "revision": 2, "date": "2026-08-08", "change": "added admin/list-users, create-user (explicit-password-wins §E12 #3031/#3033, signs in), set-role (flips gate outcomes), remove-user, revoke-user-sessions (kills live session mid-flight), each non-admin-refused; plus the §A5 engine cascade exemption clause (delete sys_user → owned rows' owner_id set_null; owner-anchor guard does not veto the system-context cascade, #3023/#3048) (PENDING-GAPS §D + §G)", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 3, "date": "2026-08-11", "change": "CORRECTION from run #7663: the revoke-user-sessions clause named get-session's status code as its oracle and expected 401. better-auth's no-session convention is HTTP 200 with a JSON null body (session-of-record.test.ts:165), so the literal 401 expectation misdescribes a CORRECT implementation and a status-only assertion would also pass against a live session's absence. Re-pointed the clause, the step and the negative at the authed-request oracle — a protected request the target could serve a moment earlier, refused on the very next call — with get-session's body kept as corroboration only. The session was provably gone in the run; only the oracle was wrong", "ref": "#7740" } ] }, { @@ -726,7 +730,7 @@ "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, + "revision": 2, "priority": "P1", "surface": "mixed", "personas": ["tenant admin / org owner", "a target org member", "a non-admin org member (forger)"], @@ -740,7 +744,8 @@ "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", + "change a member's role: POST /api/v1/auth/organization/update-member-role (client organizations.updateMemberRole) to one of the ADR-0108 closed 4-name vocabulary {owner, admin, delegated_admin, member}; read the membership back and confirm the new role", + "prove the vocabulary is CLOSED, not merely conventional: attempt the same call with role 'guest' (and with any stack-declared position/permission-set name, e.g. showcase's 'contributor') — better-auth's role check refuses it (400 ROLE_NOT_FOUND) before any insert, and no membership/invitation row is left behind", "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", @@ -755,11 +760,17 @@ "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", + "clause": "update-member-role writes a role from the ADR-0108 closed vocabulary and it bites: the membership read shows the new role (one of owner/admin/delegated_admin/member) 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", + "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. The four names are BUILTIN_MEMBERSHIP_ROLE_OPTIONS (packages/spec/src/identity/membership-role.ts:116) — the registered select options for sys_member.role and sys_invitation.role, pinned by packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts", "evidence": "the membership read + the bracketing gated requests" }, + { + "clause": "the vocabulary is CLOSED at the door: role 'guest' — and any stack-declared position or permission-set name — is REFUSED (400 ROLE_NOT_FOUND) rather than stored, and leaves no row behind. `guest` is not a membership role on this platform; `delegated_admin` is", + "oracle": "api", + "verify": "the update-member-role (or invite-member) call with role 'guest' returns non-2xx with ROLE_NOT_FOUND; a follow-up sys_member/sys_invitation read shows no row. Nothing widens the option list at boot any more (ADR-0108 — no withMembershipRoleOptions, no kernel:ready re-registration)", + "evidence": "the refusal response + the follow-up read" + }, { "clause": "rename via organization/update persists: get-full-organization returns the new name and the surface follows", "oracle": "api", @@ -788,18 +799,22 @@ "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", + "a role written outside the {owner, admin, delegated_admin, member} vocabulary is a FAIL — including a stored 'guest': the closed list is the write-side guardrail that makes an ungoverned capability grant unrepresentable (ADR-0108), so a 2xx that persists 'guest' is a regression of the closure, not a vocabulary difference. A role change that does not flip any gate is equally 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/spec/src/identity/membership-role.ts (BUILTIN_MEMBERSHIP_ROLES / BUILTIN_MEMBERSHIP_ROLE_OPTIONS — THE role vocabulary: owner/admin/delegated_admin/member, ADR-0108; 'nothing widens these at boot any more')", + "docs/adr/0108-membership-grade-is-not-a-capability-channel.md (why the list is closed: a grade decides what you can REACH, never a bundle of what you may do)", + "packages/qa/dogfood/test/membership-role-vocabulary.dogfood.test.ts (both enforced selects offer exactly the four; a declared position or PermissionSet name is refused at better-auth's role check — ROLE_NOT_FOUND — before any insert)", + "packages/platform-objects/src/identity/sys-member.object.ts + sys-invitation.object.ts (role select options: [...BUILTIN_MEMBERSHIP_ROLE_OPTIONS])", "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" } + { "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" }, + { "revision": 2, "date": "2026-08-11", "change": "CORRECTION from run #7663: the role vocabulary named here was wrong. The enforced builtin set is {owner, admin, delegated_admin, member} (ADR-0108 BUILTIN_MEMBERSHIP_ROLE_OPTIONS), NOT {owner, admin, member, guest} — 'guest' is rejected 400 ROLE_NOT_FOUND and delegated_admin is legitimate. Corrected the step, the acceptance clause and the negative; added a closed-vocabulary clause (guest / a stack position / a PermissionSet name each refused, no row left behind); re-pointed source at membership-role.ts + ADR-0108 + the vocabulary dogfood pin instead of the stale organization.zod.ts doc-comment the wrong text came from (that doc-comment is fixed in the same PR)", "ref": "#7740" } ] }, { @@ -807,7 +822,7 @@ "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, + "revision": 2, "priority": "P2", "surface": "mixed", "personas": ["org admin", "two members to place on a team", "a scope-limited persona whose read follows the BU tree"], @@ -816,10 +831,11 @@ "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" + "for the scope-geometry clause: the showcase DOES ship a BU-consuming geometry — the `share_new_inquiries_with_field_ops` sharing rule expands the `bu_field_ops` SUBTREE (Field Operations + West/East Coast) onto showcase_inquiry, and the `showcase_field_ops_delegate` adminScope is bounded by the same tree (examples/app-showcase/src/security/sharing-rules.ts:63, seed/index.ts:200-218). The clause RUNS; it is not blocked" ], "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" + "RETIRED (run #7663): the old gap read 'whether a stock persona's read is scoped BY the business-unit tree depends on the seeded config; if none consumes it the tree-widening clause is blocked(fixture)'. A BU-consuming geometry does ship (`share_new_inquiries_with_field_ops` → the bu_field_ops subtree), so the clause runs on stock showcase. Kept as a retired line rather than deleted so a future sweep does not re-block the clause on the retired reasoning", + "ZERO SEEDED PLACEMENTS — the `seed-data-thin` trap for this item, and the thing that actually costs a run: a fresh boot seeds the sys_business_unit TREE (explicit ids, so metadata can reference units statically) but ZERO `sys_business_unit_member` rows — 'users can't be seeded (they sign up), so user↔unit membership and position assignments stay runtime admin actions' (examples/app-showcase/src/data/seed/index.ts:216-218). The sharing rule therefore materializes NOTHING until the tester places someone: create the sys_business_unit_member row FIRST, then read the scoped persona's rows. An empty before/after diff with no placement made is a fixture artifact, not a geometry failure" ] }, "steps": [ @@ -827,7 +843,7 @@ "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)", + "BU scope geometry IS configured on stock showcase — `share_new_inquiries_with_field_ops` expands the bu_field_ops subtree onto showcase_inquiry: as the scope-limited persona, record the showcase_inquiry row set visible BEFORE any placement (expect it EMPTY of shared rows — a fresh boot seeds zero sys_business_unit_member rows, so the rule matches nobody yet), then place the persona into bu_field_ops (or a descendant) and re-read — the visible set should WIDEN along the subtree (cross-ref 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" ], @@ -851,10 +867,10 @@ "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", + "clause": "the shipped BU geometry is load-bearing: 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)" + "verify": "the scoped persona's row set before vs after the placement/re-parent differs exactly by the subtree `share_new_inquiries_with_field_ops` expands (bu_field_ops + descendants). The BEFORE read is expected to show none of the shared rows — a fresh boot seeds the tree but zero sys_business_unit_member rows, so the rule materializes nothing until the placement exists; make the placement, do not record a block", + "evidence": "the before/after scoped reads + the sys_business_unit_member row that made the difference" }, { "clause": "team and BU mutations are admin-gated: a non-admin's create-team/add-team-member and BU writes are refused server-side", @@ -873,10 +889,13 @@ "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)" + "docs/qa/platform-checklist/areas/access-security.json (access-security.scope-depth-asymmetry — the depth matrix this cross-references for the tree-widening geometry)", + "examples/app-showcase/src/security/sharing-rules.ts:63 (`share_new_inquiries_with_field_ops` — the shipped BU-consuming geometry: expands the bu_field_ops subtree onto showcase_inquiry)", + "examples/app-showcase/src/data/seed/index.ts:200-218 (the sys_business_unit tree is seeded with explicit ids; user↔unit membership — sys_business_unit_member — and position assignments are NOT seeded, they stay runtime admin actions)" ], "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" } + { "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" }, + { "revision": 2, "date": "2026-08-11", "change": "from run #7663: retired the 'maybe nothing consumes the BU tree' knownGap — a BU-consuming geometry DOES ship (`share_new_inquiries_with_field_ops` expands the bu_field_ops subtree onto showcase_inquiry), so the tree-widening clause runs on stock showcase instead of being blocked(fixture). Replaced it with the zero-seeded-placements note that actually costs runs: a fresh boot seeds the BU tree but ZERO sys_business_unit_member rows, so the rule materializes nothing until the tester places someone — the seed-data-thin trap for this item. Step and clause now say make the placement, expect an empty BEFORE, and do not record a block", "ref": "#7740" } ] }, { @@ -1117,7 +1136,7 @@ "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, + "revision": 2, "priority": "P2", "surface": "mixed", "personas": ["platform admin (running the import)", "an imported user (signing in afterwards)", "a non-admin (forger)"], @@ -1129,7 +1148,8 @@ "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" + "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", + "the `auto` policy's TEMPORARY-FALLBACK branch is UNREACHABLE on `objectstack dev` (run #7663) — do not score it as a missing behaviour and do not re-derive this next sweep. `auto` falls back only when a row is neither email- nor SMS-deliverable (admin-import-users.ts:216-221), and on dev neither can be made false: service-email / service-sms always register (a log transport is the no-provider fallback, service-sms/src/sms-plugin.ts:149,169), so isEmailServiceAvailable() is a bare 'is a service wired?' check (auth-manager.ts:4054) and returns true; and isPhoneOtpDeliverable() (auth-manager.ts:4074) only returns false for an unconfigured transport when NODE_ENV === 'production', which `objectstack dev` rules out (cli/src/commands/dev.ts:194 spawns with NODE_ENV='development'; serve.ts:490 sets it under --dev). So every email row takes the invite path, and a phone-only row without the phoneNumber plugin fails PHONE_NOT_ENABLED before any plan is chosen. To exercise the fallback, boot WITHOUT an email service (or with NODE_ENV=production + an unconfigured SMS transport) — otherwise score the fallback via the `temporary` policy, which forces the same credential path for every row" ] }, "steps": [ @@ -1145,7 +1165,7 @@ { "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)", + "verify": "the import response's per-row results show action + rows[].delivery ('email' | 'sms' | 'temporary'); deliverable rows show an invitation outcome (observed at the dev transport). On `objectstack dev` the fallback HALF of this clause is not reachable — see the fixture note: every row is deliverable there, so score the invite half and record the fallback half as not-exercised-by-fixture (NOT as a defect); the fallback credential path itself is covered by the `temporary` clause below", "evidence": "the import response + the transport capture + the reveal screenshot" }, { @@ -1194,10 +1214,12 @@ "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)" + "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)", + "packages/plugins/plugin-auth/src/admin-import-users.ts:216-221 (the `auto` per-row plan: invite where email- or SMS-deliverable, temporary only otherwise) + auth-manager.ts:4054,4074 (isEmailServiceAvailable / isPhoneOtpDeliverable — the two gates that decide it)" ], "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" } + { "revision": 1, "date": "2026-08-08", "change": "new item: admin CSV identity import with password-policy matrix (auto/temporary/invite/none), imported-user sign-in, upsert idempotency, response-only one-time passwords, non-admin denied, grounded in objectui identityImport.ts + admin-user-endpoints.ts (PENDING-GAPS §G)", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-08-11", "change": "from run #7663: recorded the fixture note that the `auto` policy's temporary-fallback branch cannot occur on `objectstack dev` — both transports always register (log fallback) and dev pins NODE_ENV='development', so neither deliverability gate can be made false and every row takes the invite path. The clause now says score the invite half and record the fallback half as not-exercised-by-fixture rather than as a defect, and names the boot that WOULD exercise it. Recorded so the next sweep does not re-derive it", "ref": "#7740" } ] } ] diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index 83bc2b0665..0c80879216 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -427,7 +427,7 @@ "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, + "revision": 3, "priority": "P2", "surface": "browser", "personas": [ @@ -436,12 +436,16 @@ "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)" + "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)", + "a flow that is EDITABLE in Studio — use a shipped one (showcase_declarative_connector_ping) or create it from the console's own create page; see the knownGap before authoring one over the metadata API" + ], + "knownGaps": [ + "A FLOW AUTHORED OVER THE METADATA API IS NOT EDITABLE IN STUDIO (run #7690, cross-observed by run #7695 / #7753 item 3) — with or without `?package=`, it opens behind 'This flow is provided by an installed package, so it is read-only at runtime', so a run that provisions its fixture flow via PUT /api/v1/meta/flows/... cannot then drive the picker on it. objectui's ResourceEditPage treats an item as artifact-backed when `layered.code != null && _packageId !== 'sys_metadata'`, and `flow` declares allowOrgOverride:false — so for THIS type the banner is arguably telling the truth (the server would refuse the overlay write anyway). The editable path is the console's own create page. Recorded, not filed: the polarity-reversed case, where the same heuristic locks a WRITABLE-package object it should not, is the real defect and lives at objectui#4308" ] }, "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", + "in Studio, open a flow (e.g. showcase_declarative_connector_ping) and add/select a connector_action node — if the editor opens behind the 'provided by an installed package' banner, check how the flow was authored before filing anything (see the knownGap: metadata-API-authored flows are read-only in Studio)", "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" @@ -491,6 +495,12 @@ "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-11", + "change": "recorded the run #7690 route note that a flow authored over the metadata API opens read-only in Studio ('provided by an installed package'): ResourceEditPage's artifact-backed heuristic (layered.code != null && _packageId !== 'sys_metadata') plus flow's allowOrgOverride:false. The fixture now names an editable flow and the step says check the authoring route before filing. Kept as a note, not a defect — the polarity-reversed case is objectui#4308", + "ref": "#7745" } ] }, @@ -499,7 +509,7 @@ "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, + "revision": 4, "priority": "P1", "surface": "mixed", "fixtures": { @@ -507,7 +517,10 @@ "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)" + "a predicate multi-write path for the bulk variants (update/delete with multi:true on showcase_task) — NOT reachable over REST, see the knownGap below: author a flow with an `update_record` / `delete_record` node carrying multi:true and fire it through the api trigger" + ], + "knownGaps": [ + "PREDICATE (multi:true) WRITES ARE UNREACHABLE OVER REST BY DESIGN (run #7690) — do not re-derive this, and do not file the 400 as a defect. #3897 made the batch routes parse their body against the spec contract, and Zod object schemas STRIP unknown keys: `options.multi` and `options.where` can no longer ride into the engine's delete/update options, and `POST /data/:object/deleteMany` now deletes per id (packages/rest/src/rest-server.ts:10461-10492). That is a security boundary, not a gap. The bulk_update/bulk_delete clause therefore needs a FLOW `update_record` / `delete_record` node authored with multi:true, fired through the api trigger — that is the supported predicate-write door" ] }, "variants": [ @@ -523,7 +536,7 @@ "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", + "run a predicate multi-update and multi-delete (multi:true) matching several rows — through a FLOW node, not REST: author update_record / delete_record nodes with multi:true and fire them via the api trigger (the REST batch routes strip options.multi, #3897 — see the knownGap); 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", @@ -585,6 +598,7 @@ "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)", + "packages/rest/src/rest-server.ts:10461-10492 (#3897 — the batch routes parse against the spec contract and Zod STRIPS unknown keys, so options.multi/options.where cannot ride in; deleteMany deletes per id. This is why the bulk clause must be driven from a flow node, not REST)", "examples/app-showcase/src/automation/webhooks/index.ts (the shipped inactive fixture and its activation story)", "#3358 §9 (webhook undelete/api trigger removal gate)" ], @@ -606,6 +620,12 @@ "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" + }, + { + "revision": 4, + "date": "2026-08-11", + "change": "recorded the run #7690 note that predicate (multi:true) writes are unreachable over REST BY DESIGN — #3897 strips options.multi from the batch routes as a security boundary — so the bulk_update/bulk_delete clause must be driven from a flow update_record/delete_record node fired through the api trigger. Landed as a fixtures.knownGaps entry + a corrected step + a source citation so the next sweep does not re-derive it or file the 400 as a defect", + "ref": "#7745" } ], "enumSource": { @@ -720,7 +740,7 @@ "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, + "revision": 4, "priority": "P2", "surface": "mixed", "fixtures": { @@ -753,7 +773,8 @@ "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" + "drive the raw send door POST /api/v1/email/send three ways: (a) authed with a well-formed message { to, subject, html } (or `text`; SendEmailInput's body keys are `html`/`text` — `bodyHtml`/`bodyText` are the TEMPLATE authoring fields, not wire keys) → capture status + the dev-transport landing; (b) anonymous (no session) → capture status; (c) a non-object / malformed body → capture the envelope", + "while you are at the raw door, send the wrong-vocabulary body { to, subject, bodyHtml } deliberately and capture the refusal — 400 VALIDATION_FAILED 'at least one of text or html is required'. This is the shape the checklist itself used to name (run #7690); keeping the probe pins the two vocabularies apart" ], "acceptance": [ { @@ -799,15 +820,16 @@ "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", + "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 — { to, subject } plus AT LEAST ONE of `html` / `text` — 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" + "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). ⚠ WIRE KEYS (corrected, run #7690): the send input is SendEmailInput { to, subject, text?, html?, from?, cc?, bcc?, replyTo?, … } (packages/spec/src/contracts/email-service.ts:42-68) — NOT `bodyHtml`. `bodyHtml`/`bodyText` are the email-TEMPLATE authoring fields (this item's own variants list at the `body:` and alias lines says so); posting them to this door is refused 400 'VALIDATION_FAILED: at least one of text or html is required' (packages/plugins/plugin-email/src/email-service.ts:232). A 400 here is the run using the wrong vocabulary, not a product defect — re-send with `html` before filing anything", + "evidence": "the three responses + the dev-transport landing (plus the wrong-vocabulary refusal, if the probe was driven)" } ], "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)" + "a send with an unresolved {{placeholder}} residue delivered to the transport is a FAIL (render must substitute or refuse, never ship holes)", + "NOT a FAIL: POST /api/v1/email/send answering 400 'at least one of text or html is required' to a body carrying `bodyHtml`. That is the two vocabularies being kept apart, correctly — template authoring uses bodyHtml/bodyText, the wire uses html/text (run #7690 corrected this checklist's own text)" ], "traps": [ "stale-dist" @@ -823,7 +845,8 @@ "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)" + "packages/spec/src/api/error-code-ledger.zod.ts (EMAIL_SEND_FAILED under @objectstack/rest)", + "packages/spec/src/contracts/email-service.ts:42-68 (SendEmailInput — the WIRE shape of POST /api/v1/email/send: to, subject, text?, html?; 'at least one of text or html must be supplied') + packages/plugins/plugin-email/src/email-service.ts:220-240 (normalizeMessage — where the VALIDATION_FAILED texts come from)" ], "history": [ { @@ -843,6 +866,12 @@ "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" + }, + { + "revision": 4, + "date": "2026-08-11", + "change": "CORRECTION from run #7690: the raw-send step named a { to, subject, bodyHtml } message body, but those are the TEMPLATE authoring fields — the wire keys are `html`/`text` (SendEmailInput, email-service.ts:42-68), and the literal shape the step named is refused 400 'at least one of text or html is required'. Corrected the step and the clause, added the wrong-vocabulary probe that pins the two vocabularies apart, added a NOT-a-FAIL negative so the refusal is not filed as a defect next run, and cited the contract + normalizeMessage in source", + "ref": "#7745" } ] }, @@ -851,7 +880,7 @@ "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, + "revision": 2, "priority": "P1", "surface": "mixed", "personas": [ @@ -864,10 +893,11 @@ "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" + "three personas with sessions: admin, the assignee, an unrelated member — mint them by SIGNING THEM UP (or via POST /api/v1/auth/admin/create-user), not by grafting a credential onto a seeded demo persona; see the knownGap" ], "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" + "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", + "THE OLD PERSONA-LOGIN RECIPE IS CLOSED (run #7690): `POST /api/v1/data/sys_account` answers 405, so the trick of inserting a credential row to turn a seeded demo persona into a real login no longer works. It is by design, not drift — sys_account is managedBy:'better-auth' and declares apiMethods ['get','list'] only, so HTTP answers 405 before the identity write guard's 403 (packages/platform-objects/src/identity/sys-account.object.ts:234-241, #1591 / ADR-0092 D2). Mint personas through better-auth instead: sign-up, or POST /api/v1/auth/admin/create-user as the platform admin (explicit password wins — #3031/#3033). Recorded here because every multi-persona item in this area pays for re-deriving it" ] }, "variants": [ @@ -938,6 +968,12 @@ "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" + }, + { + "revision": 2, + "date": "2026-08-11", + "change": "recorded the run #7690 fixture note that POST /api/v1/data/sys_account now answers 405, closing the old recipe for turning a seeded demo persona into a real login (sys_account is managedBy better-auth with apiMethods ['get','list'] — 405 before the identity write guard's 403, #1591 / ADR-0092 D2). The three-persona requirement now names the supported way to mint them (sign-up / admin create-user). Notes only — no clause changed", + "ref": "#7745" } ] }, diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 923a076bb2..683faf0ff7 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -365,7 +365,7 @@ "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, + "revision": 2, "priority": "P1", "surface": "mixed", "personas": ["seeded admin (admin@objectos.ai / admin123)"], @@ -401,7 +401,7 @@ { "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)", + "verify": "capture the PUT during Studio create. ⚠ Drive it by hand: the pin (objectui e2e/live/studio-record-page.spec.ts, which waits on exactly that request) is STALE against the current surface — it fills the Object control as an input when it is now a role=combobox button — see automated.stale", "evidence": "the captured PUT" }, { @@ -429,7 +429,19 @@ "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" }, + "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", + "stale": { + "since": "2026-08-11", + "observedIn": "#7695", + "ref": "#7753 item 6", + "partial": "objectui: e2e/live/studio-record-page.spec.ts ONLY", + "why": "STALE, NOT RED — that spec fills the Object control as an input when it is now a `role=combobox` button, so it fails against the CURRENT surface rather than against a product defect. The dogfood pin (package-first-authoring) and studio-object-designer.spec.ts are unaffected; object-designer-roundtrip's 4th ref, from the same class, was already repaired (package-create moved to /_console/studio).", + "runnerRule": "The Studio-designer clause here (clause 3, 'creating a record page issues PUT /api/v1/meta/page/') must be driven by hand — capture the PUT in the browser — until the spec is re-pointed; do not cite its output and do not score its failure as this item's FAIL. Every other clause keeps its own oracle.", + "ownedBy": "objectui — re-pointing the spec is a cross-repo half of #7753, reported not edited from this repo" + } + }, "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)", @@ -437,7 +449,8 @@ "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" } + { "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" }, + { "revision": 2, "date": "2026-08-11", "change": "annotated the objectui e2e/live/studio-record-page.spec.ts half of this item's automated.ref as STALE (run #7695): it fills the Object control as an input when it is now a role=combobox button, so it fails against the current surface, not against a defect. The Studio-designer clause now says drive it by hand until the spec is re-pointed; the dogfood pin and studio-object-designer.spec.ts are unaffected. Re-pointing lives in objectui (cross-repo half of #7753 item 6)", "ref": "#7753" } ] }, { diff --git a/docs/qa/platform-checklist/areas/studio-authoring.json b/docs/qa/platform-checklist/areas/studio-authoring.json index 03b51be3f4..7dd2ae105d 100644 --- a/docs/qa/platform-checklist/areas/studio-authoring.json +++ b/docs/qa/platform-checklist/areas/studio-authoring.json @@ -227,7 +227,7 @@ "title": "Record-page authoring round-trip: created bound to its object, seeded from the default layout, block-edited, published, rendered", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "mixed", "personas": ["admin (authors)", "end user (opens the record)"], @@ -249,13 +249,13 @@ { "clause": "the created page persists bound to its object with PRE-SEEDED regions — record:highlights present, regions non-empty", "oracle": "network", - "verify": "the captured PUT body has type:'record', object:'showcase_invoice', Array.isArray(regions) with blocks including record:highlights (pinned by objectui e2e/live/studio-record-page.spec.ts)", + "verify": "the captured PUT body has type:'record', object:'showcase_invoice', Array.isArray(regions) with blocks including record:highlights. ⚠ Drive this by hand: the pin (objectui e2e/live/studio-record-page.spec.ts) is STALE against the current surface — it fills the Object control as an input when it is now a role=combobox button — see automated.stale", "evidence": "the captured PUT payload" }, { "clause": "the page editor exposes block authoring: the picker offers schema-backed block kinds (Card, Section, Record details)", "oracle": "dom", - "verify": "after a screenshot confirms the editor rendered, the Add-block dialog lists the three kinds (pinned by objectui e2e/live/studio-editor.spec.ts)", + "verify": "after a screenshot confirms the editor rendered, the Add-block dialog lists the three kinds. ⚠ Drive this by hand: the pin (objectui e2e/live/studio-editor.spec.ts) is STALE — it targets a 'Layout' heading that no longer exists and a shipped page the editor correctly locks — see automated.stale", "evidence": "screenshot + dialog DOM read" }, { @@ -275,13 +275,26 @@ "a new record page opening as a blank canvas (regions: []) is objectui#1541 regressed — the createSeed seeding is the point of the item; FAIL" ], "traps": ["stale-console-bundle", "hydration-race", "automation-input"], - "automated": { "kind": "e2e", "ref": "objectui: e2e/live/studio-record-page.spec.ts" }, + "automated": { + "kind": "e2e", + "ref": "objectui: e2e/live/studio-record-page.spec.ts", + "stale": { + "since": "2026-08-11", + "observedIn": "#7695", + "ref": "#7753 item 6", + "why": "STALE, NOT RED — the spec fails against the CURRENT surface, not against a product defect. `studio-record-page.spec.ts` fills the Object control as an input when it is now a `role=combobox` button; `studio-editor.spec.ts` (cited in source, and the pin behind the Add-block clause) targets a 'Layout' heading that no longer exists, plus a SHIPPED page the editor correctly locks. Neither is reporting a regression.", + "runnerRule": "Do NOT satisfy this item by citing these specs' output, and do NOT score their failure as a FAIL of this item — drive the clauses by hand (browser + the captured PUT) until the specs are re-pointed. A stale ref left unmarked costs a red run every time the area is exercised, which is how a stale ref turns into ignored signal.", + "ownedBy": "objectui — re-pointing the specs at the current surface is a cross-repo half of #7753, reported not edited from this repo", + "seeAlso": "the same class, already repaired: object-designer-roundtrip's 4th automated ref failed only because package-create moved to /_console/studio; that contract passes on the current surface" + } + }, "source": [ - "objectui: e2e/live/studio-record-page.spec.ts (#1541, ADR-0034 — create bound + seeded regions, asserted off the PUT payload)", - "objectui: e2e/live/studio-editor.spec.ts (page editor sections + Add-block picker contract)" + "objectui: e2e/live/studio-record-page.spec.ts (#1541, ADR-0034 — create bound + seeded regions, asserted off the PUT payload) — ⚠ STALE against the current surface, see automated.stale", + "objectui: e2e/live/studio-editor.spec.ts (page editor sections + Add-block picker contract) — ⚠ STALE against the current surface, see automated.stale" ], "history": [ - { "revision": 1, "date": "2026-08-07", "change": "new item: record-page authoring round-trip pinned to the two objectui live e2e specs (create-seeded draft, block picker) and extended to the publish + end-user render sides they do not cover", "ref": "claude/platform-test-checklist-ocwugl" } + { "revision": 1, "date": "2026-08-07", "change": "new item: record-page authoring round-trip pinned to the two objectui live e2e specs (create-seeded draft, block picker) and extended to the publish + end-user render sides they do not cover", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-08-11", "change": "marked both pinning specs STALE (run #7695): studio-record-page.spec.ts fills the Object control as an input when it is now a role=combobox button, and studio-editor.spec.ts targets a 'Layout' heading that no longer exists plus a shipped page the editor correctly locks. Neither reports a product defect — the annotation tells a runner not to cite their output and not to score their failure as this item's FAIL. Re-pointing the specs lives in objectui (cross-repo half of #7753 item 6); this repo only records the staleness", "ref": "#7753" } ] }, { @@ -289,7 +302,7 @@ "title": "Metadata draft→publish lifecycle: drafts staged not served, publish flips visibility atomically, conflicts and invalid drafts refused, and the history/audit/diff/rollback forensics tell the truth", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": ["admin (authors drafts)", "end user (must not see drafts)"], @@ -307,7 +320,7 @@ "publish per-ref: POST /api/v1/meta/dashboard/qa_lifecycle_probe/publish → 200; GET now serves the authored body", "author a SECOND draft revision on the same name and confirm the live read keeps serving revision 1 until that draft is published", "concurrency guard: re-PUT with the STALE version as If-Match and capture the 409", - "package-wide door: stage two drafts in the writable package and POST /api/v1/packages//publish-drafts — both flip in one atomic release (the audit's 'Published all drafts in this package (one atomic release)' toast)", + "package-wide door: stage two drafts in the writable package and POST /api/v1/packages//publish-drafts — both flip in one atomic release (the audit's 'Published all drafts in this package (one atomic release)' toast); capture the STATUS and the BODY separately, because the abort path answers 200 with data.success:false (see the clause below)", "author an INVALID draft (a widget carrying a stray legacy key) and attempt its publish — the author-time gate must reject the draft→active transition (#4463)", "meta forensics (after the two publishes of qa_lifecycle_probe — revision 1, then the second revision): GET /api/v1/meta/dashboard/qa_lifecycle_probe/history — the durable sys_metadata_history events must list BOTH published revisions (a dashboard is an overlay type, so history is real; a non-overlay type answers { events: [] } by design)", "GET /api/v1/meta/dashboard/qa_lifecycle_probe/diff?from=1&to=2 (or omit the params for previous-vs-current) — the structural diff must name the widget key that changed between the revisions, not dump the whole body", @@ -343,8 +356,8 @@ { "clause": "package-wide publish-drafts promotes every pending draft in one atomic release — and a non-compliant draft (e.g. an object draft missing the package namespace prefix) aborts the batch BEFORE any promotion", "oracle": "api", - "verify": "POST /api/v1/packages//publish-drafts flips both staged drafts; the namespace-gate rejection path leaves ALL drafts unpromoted (packages/objectql/src/protocol-publish-package-drafts.test.ts pins the atomicity)", - "evidence": "the publish-drafts response + post-state reads" + "verify": "POST /api/v1/packages//publish-drafts flips both staged drafts; the namespace-gate rejection path leaves ALL drafts unpromoted (packages/objectql/src/protocol-publish-package-drafts.test.ts pins the atomicity). ⚠ SCORE THE ABORT OFF THE BODY, NOT THE STATUS (run #7695): the namespace-gate abort answers HTTP 200 with data.success:false, so a status-only assertion reads an aborted publish as a successful one. Read data.success and then prove the atomicity — the run verified the abort leaves BOTH names 404. Whether 200+success:false or a 4xx is the intended contract for this door is an OPEN LEDGER QUESTION recorded at #7753 item 2, awaiting one deliberate ruling; until it is ruled, assert the CURRENT behaviour and do not file the status code as a defect", + "evidence": "the publish-drafts response (status AND body) + post-state reads showing both names still 404" }, { "clause": "an invalid draft cannot cross into active: the author-time rules gate the draft→active transition (#4463) — publish of the stray-key draft is refused", @@ -387,7 +400,8 @@ "a draft body served to end users before publish is the lifecycle FAIL this item exists for", "a publish that answers 200 while the live read still serves the old body is a FAIL — the ADR-0045 visibility flip failing loudly is exactly the path packages.ts warns about, and silence there is worse than the warning", "a forensics route consulted on the dispatcher instead of the REST route-manager server is a recording error — the dispatcher /meta branch swallows /history as a compound name and 404s (rest-route-ledger.ts note); the oracle is the live server os dev serves", - "a rollback that answers 200 while the live read still serves the newer revision is a FAIL (the restore must actually flip the served body)" + "a rollback that answers 200 while the live read still serves the newer revision is a FAIL (the restore must actually flip the served body)", + "OPEN LEDGER QUESTION, not a verdict (run #7695 → #7753 item 2): publish-drafts' namespace-gate abort answers HTTP 200 with data.success:false rather than a 4xx. The envelope-level success flag may well be the intended contract for this door, and the atomicity behind it is correct (the abort leaves both names 404) — but an HTTP-status-only client reads an aborted publish as a successful one, which is why it is worth ONE deliberate ruling. Recorded here so the observation is not re-derived every run; a runner does not adjudicate it and does not score it as a FAIL" ], "traps": ["hydration-race", "dispatcher-vs-hono-route"], "automated": { "kind": "e2e", "ref": "packages/qa/dogfood/test/dashboard-designer-roundtrip.dogfood.test.ts" }, @@ -401,7 +415,8 @@ ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "new item: both sides of the draft→publish gate (staged-not-served / flipped-on-publish), plus OCC 409, atomic package-wide publish, and the #4463 invalid-draft publish refusal — grounded in the spec receipt schema, the runtime publish-drafts handler, and the pinned dogfood roundtrip", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-08-08", "change": "clause-extension (routes hunter #12): meta forensics — GET /meta/:type/:name/{history,audit,diff} + POST .../rollback (two publishes → history lists both, diff names the changed key, rollback restores rev-1 and the live app serves it, audit rows carry the actor); traps gain dispatcher-vs-hono-route (the dispatcher /meta branch 404s /history)", "ref": "claude/platform-test-checklist-ocwugl" } + { "revision": 2, "date": "2026-08-08", "change": "clause-extension (routes hunter #12): meta forensics — GET /meta/:type/:name/{history,audit,diff} + POST .../rollback (two publishes → history lists both, diff names the changed key, rollback restores rev-1 and the live app serves it, audit rows carry the actor); traps gain dispatcher-vs-hono-route (the dispatcher /meta branch 404s /history)", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 3, "date": "2026-08-11", "change": "recorded the run #7695 ledger note that publish-drafts' namespace-gate abort answers HTTP 200 with data.success:false, not a 4xx: the step and the clause now say capture status and body separately and score the abort off data.success (the atomicity itself was verified — the abort leaves both names 404). Logged in negatives as an OPEN ledger question awaiting one deliberate ruling (#7753 item 2) — deliberately RECORDED, not adjudicated: a runner asserts the current behaviour and does not file the status code as a defect", "ref": "#7753" } ] }, { @@ -409,13 +424,16 @@ "title": "An invalid authored shape is rejected at save with a LOCATED error and is not persisted", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "mixed", "personas": ["admin"], "fixtures": { "app": "showcase", - "requires": ["scratch metadata names only (qa_invalid_probe / qa_invalid_views) — rejected drafts must leave nothing behind, so no cleanup dependency"] + "requires": ["scratch metadata names only (qa_invalid_probe / qa_invalid_views) — rejected drafts must leave nothing behind, so no cleanup dependency"], + "knownGaps": [ + "KNOWN SPURIOUS BANNER — do not file it as new (run #7695's sighting recorded here so it is not re-filed a third time): a freshly saved VALID draft can show \"Unrecognized key(s) on this object: `_diagnostics`\". The designer re-validates the server's OWN annotation on read-back — `_diagnostics` is added by the server to the saved body, and the strict client-side schema then rejects it. It is a false positive on a save that succeeded, and it is the exact inverse of what this item asserts (a LOCATED error on an INVALID shape), so it is worth telling apart on sight: if the banner names `_diagnostics` and the save returned 2xx, it is this known issue, not a validation failure" + ] }, "steps": [ "attempt PUT /api/v1/meta/object/qa_invalid_probe?mode=draft with a field MISSING its type; capture the status and full error body", @@ -459,7 +477,8 @@ ], "negative": [ "a 2xx on an invalid draft, or a rejection that leaves the invalid body readable afterwards, is a FAIL", - "silent client-side swallowing — no visible error after a failed save — is a FAIL even though the server refused correctly (the author must SEE the located error)" + "silent client-side swallowing — no visible error after a failed save — is a FAIL even though the server refused correctly (the author must SEE the located error)", + "NOT this item's FAIL: an \"Unrecognized key(s) on this object: `_diagnostics`\" banner on a draft that SAVED successfully — that is the known designer-side re-validation of the server's own annotation (see the knownGap), not a validation gate misfiring on the authored shape" ], "traps": ["stale-console-bundle", "automation-input"], "source": [ @@ -468,7 +487,8 @@ "dashboards.strict-widget-rejects-stray-keys (dashboard-kind stray keys — cross-referenced, not duplicated)" ], "history": [ - { "revision": 1, "date": "2026-08-07", "change": "new item: authoring validation with located errors and verified non-persistence, sampling object + view kinds and cross-referencing the deepened dashboard stray-key item instead of duplicating it", "ref": "claude/platform-test-checklist-ocwugl" } + { "revision": 1, "date": "2026-08-07", "change": "new item: authoring validation with located errors and verified non-persistence, sampling object + view kinds and cross-referencing the deepened dashboard stray-key item instead of duplicating it", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-08-11", "change": "recorded run #7695's sighting of the ALREADY-KNOWN spurious '_diagnostics' banner on a freshly saved VALID draft (the designer re-validates the server's own annotation on read-back) as a knownGap + a NOT-this-item's-FAIL negative, so the sighting is not filed as a new defect and the false positive is told apart from a real located-error miss. Note only; no clause changed", "ref": "#7753" } ] }, { @@ -476,7 +496,7 @@ "title": "The metadata type registry gates runtime writes: allowOrgOverride=false kinds refuse overlay (403 not_overridable), allowRuntimeCreate=false kinds refuse creation — both sides", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": ["admin"], @@ -484,6 +504,10 @@ "app": "showcase", "requires": [ "the stock showcase artifact (artifact-backed objects like showcase_task and packaged views are the locked targets; a scratch name serves the allowed-create side)" + ], + "knownGaps": [ + "CORRECTION TO RUN #7637 (recorded by run #7695): the Studio read-only badge is HONEST on this build/pin — it is not asserting a lock the server declines to apply. With OS_METADATA_WRITABLE=permission set, GET /meta reports permission { allowOrgOverride: true, overrideSource: 'env' } (the enum is registry|env — packages/spec/src/api/protocol.zod.ts:208) and the editor becomes fully writable; the badge tracks the real writable computation, proven BOTH ways in that run. #7637's contrary observation does not reproduce here — do not act on it as written, and do not re-file it", + "ROUTE NOTE (run #7695 / #7690): a flow authored over the metadata API is NOT editable in Studio — it opens behind 'This flow is provided by an installed package, so it is read-only at runtime', with or without `?package=`. objectui's ResourceEditPage treats an item as artifact-backed when `layered.code != null && _packageId !== 'sys_metadata'` (packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx:951-955 / 1311-1323) and `flow` declares allowOrgOverride:false, so for THIS kind the banner is arguably telling the truth: the server would refuse the overlay write anyway. The editable path is the console's own create page. Recorded as a route note, NOT a defect — the polarity-reversed case (the same heuristic locking an object published into a WRITABLE package, which Studio and the server both treat as editable) IS the defect and is filed at objectui#4308; whoever works that card should confirm this case stays correct" ] }, "steps": [ @@ -543,10 +567,13 @@ "packages/spec/src/kernel/metadata-plugin.zod.ts (DEFAULT_METADATA_TYPE_REGISTRY per-kind flags; allowOrgOverride doc: 'runtime returns HTTP 403 not_overridable'; the object/field lock rationale; job's #4509 create lock)", "packages/objectql/src/overlay-precedence.test.ts ('denied — must throw 403 (not_overridable or not_creatable)')", "packages/metadata-protocol/src/protocol.ts (isRuntimeCreateAllowed — the write-gate authority)", - "ADR-0005 (metadata customization opt-in), ADR-0049 (enforce-or-remove — the job rationale)" + "ADR-0005 (metadata customization opt-in), ADR-0049 (enforce-or-remove — the job rationale)", + "packages/spec/src/api/protocol.zod.ts:208 (overrideSource registry|env — how GET /meta reports an OS_METADATA_WRITABLE-granted override, the read that made the #7637 correction checkable)", + "objectui packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx:951-955, 1311-1323 (the artifact-backed heuristic behind the read-only banner; cross-linked to objectui#4308)" ], "history": [ - { "revision": 1, "date": "2026-08-07", "change": "new item: both sides of the registry's runtime-write gates (not_overridable / not_creatable vs accepted overlay / accepted create), variants sampled straight from DEFAULT_METADATA_TYPE_REGISTRY and pinned to the overlay-precedence suite", "ref": "claude/platform-test-checklist-ocwugl" } + { "revision": 1, "date": "2026-08-07", "change": "new item: both sides of the registry's runtime-write gates (not_overridable / not_creatable vs accepted overlay / accepted create), variants sampled straight from DEFAULT_METADATA_TYPE_REGISTRY and pinned to the overlay-precedence suite", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-08-11", "change": "recorded two notes from run #7695 — (a) the CORRECTION to run #7637: with OS_METADATA_WRITABLE=permission the Studio read-only badge clears and the editor is fully writable (GET /meta reports overrideSource:'env'), so the earlier 'badge asserts a lock the server is not applying' observation does not reproduce and must not be acted on as written; (b) the route note that a metadata-API-authored flow opens read-only in Studio (ResourceEditPage's artifact-backed heuristic + flow's allowOrgOverride:false) — correct for this kind, with the polarity-reversed defect filed at objectui#4308. Notes only; no clause, variant or oracle changed", "ref": "#7753" } ] }, { diff --git a/packages/spec/src/identity/organization.zod.ts b/packages/spec/src/identity/organization.zod.ts index 98392b29a6..a2fdb1e7eb 100644 --- a/packages/spec/src/identity/organization.zod.ts +++ b/packages/spec/src/identity/organization.zod.ts @@ -80,11 +80,19 @@ export const MemberSchema = lazySchema(() => z.object({ userId: z.string().describe('User ID'), /** - * Member's role within the organization - * Common roles: 'owner', 'admin', 'member', 'guest' - * Can be customized per application + * Member's role within the organization. + * + * The vocabulary is CLOSED (ADR-0108): `owner`, `admin`, `delegated_admin`, + * `member` — `BUILTIN_MEMBERSHIP_ROLES` / `BUILTIN_MEMBERSHIP_ROLE_OPTIONS` + * in `./membership-role.js`, which is what `sys_member.role` and + * `sys_invitation.role` register as their select options. Nothing widens the + * list at boot, and a name outside it is refused at the door + * (`ROLE_NOT_FOUND`) rather than stored — a stack that needs another + * business role declares a `position`, not a role. Typed `z.string()` here + * because the wire shape mirrors better-auth's own column, not because the + * set is open. */ - role: z.string().describe('Member role (e.g., owner, admin, member, guest)'), + role: z.string().describe('Member role (owner, admin, delegated_admin, member — ADR-0108 closed vocabulary)'), /** * Member creation timestamp @@ -127,10 +135,13 @@ export const InvitationSchema = lazySchema(() => z.object({ email: z.string().email().describe('Invitee email address'), /** - * Role the invitee will receive upon accepting - * Common roles: 'admin', 'member', 'guest' + * Role the invitee will receive upon accepting. + * + * Same closed vocabulary as {@link MemberSchema}'s `role` (ADR-0108): + * `owner`, `admin`, `delegated_admin`, `member`. A name outside it is + * refused at the door (`ROLE_NOT_FOUND`) before any invitation row exists. */ - role: z.string().describe('Role to assign upon acceptance'), + role: z.string().describe('Role to assign upon acceptance (owner, admin, delegated_admin, member — ADR-0108 closed vocabulary)'), /** * Invitation status