From 244fb01b3662bd96e1565165b0f790cde104bc7f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 02:41:58 +0000 Subject: [PATCH 1/4] docs(qa): home-admin-cluster-links re-points the three catalog redirects to the Setup metadata catalog roles, positions and permissions land on /apps/setup/metadata/TYPE?scope=environment (SystemCatalogRedirect, objectui#7611), not on the sys_position / sys_permission_set object routes; users and organizations are unchanged. Claude-Session: https://claude.ai/code/session_012dGTuJen61ECNHXYXKChoC Co-authored-by: Claude --- .../areas/platform-core.json | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index c9957b1b11..a7a381bf3d 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -2307,10 +2307,10 @@ }, { "id": "platform-core.home-admin-cluster-links", - "title": "Shell-hard-coded Administration targets + the retired System hub: every Administration target URL resolves to a real page (no RouteNotFound, no RecordDetailView for an object named 'system'), /apps/:app/system lands on the settings hub in one hop and every hub entry resolves, the five legacy system/* URLs redirect to their canonical object routes, and a member's shell offers no Administration entry", + "title": "Shell-hard-coded Administration targets + the retired System hub: every Administration target URL resolves to a real page (no RouteNotFound, no RecordDetailView for an object named 'system'), /apps/:app/system lands on the settings hub in one hop and every hub entry resolves, the five legacy system/* URLs redirect (users and organizations to their object routes, roles, positions and permissions to the Setup metadata catalog), and a member's shell offers no Administration entry", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "browser", "personas": [ @@ -2331,7 +2331,7 @@ "as admin, enumerate the Administration target list from source — UnifiedSidebar's adminItems: sys-settings → /apps/setup/system, sys-apps → …/system/apps, sys-marketplace → …/system/marketplace, sys-objects → /apps/setup/metadata/object, sys-datasources → /apps/setup/metadata/datasource, sys-users → …/system/users, sys-orgs → …/system/organizations, sys-roles → …/system/roles, sys-config → …/system/settings (9 entries; the two metadata-admin ones deliberately point at the canonical /metadata/:type routes, #3660/#3739). No sidebar renders this list at the pin (see knownGaps), so it is walked as URLs", "navigate to EVERY target URL directly: each lands on a real page — screenshot first, then assert no RouteNotFound, no 'No Apps Configured' empty state, and above all no RecordDetailView rendering for an object literally named 'system' (the historical failure: short words fell through to the record-id tail — objectui#3655's two measured landings)", "open /apps/setup/system: it redirects in ONE hop to /apps/setup/system/settings (SystemLandingRedirect — the SystemHubPage card wall, its cards and count badges, was retired by objectui#3743, carried out in objectui#10507), where SettingsHub lists the settings manifests the server returns for this user; click every hub entry the same way. The ungated bare system/ai-approvals route is ai.console-ai-surface-gating's subject, not this walk's", - "legacy redirects: navigate directly to /apps/setup/system/users, …/organizations, …/roles, …/positions, …/permissions — each redirects to its canonical object route per the #3655 map (users→sys_user, organizations→sys_organization list, roles→sys_position [ADR-0090 D3 rename — 'Roles' and 'Positions' are one surface], positions→sys_position, permissions→sys_permission_set) and the landing page renders", + "legacy redirects: navigate directly to /apps/setup/system/users, …/organizations, …/roles, …/positions, …/permissions. users and organizations redirect to their object routes per the #3655 map (users→sys_user, organizations→sys_organization list). roles, positions and permissions redirect to the Setup metadata catalog in its environment scope (SystemCatalogRedirect, objectui#7611; positions and permission sets are registry items, ADR-0131 D3): roles→/apps/setup/metadata/position?scope=environment [ADR-0090 D3 rename — 'Roles' and 'Positions' are one surface], positions→the same, permissions→/apps/setup/metadata/permission?scope=environment [#3655 decision A: the permission-set surface]. Each landing page renders", "as the plain member, open /home and one app: the shell offers no Administration entry anywhere — /home has no sidebar (HomeLayout omits it) and the top-bar Help menu offers documentation only; the server-side half (Setup app refusal for the member) is platform-core.builtin-apps-nav-render clause 2/3 — cross-reference, do not re-score here" ], "acceptance": [ @@ -2348,9 +2348,9 @@ "evidence": "the redirect trace + the per-entry verdict table" }, { - "clause": "the five legacy system/* URLs redirect per the #3655 map and land on rendering pages — bookmarks and the URLs the retired hub once emitted keep working; roles lands on sys_position (the ADR-0090 rename, one surface under two vocabularies), permissions on sys_permission_set (decision A: rules-and-assignments is layer 2)", + "clause": "the five legacy system/* URLs redirect and land on rendering pages — bookmarks and the URLs the retired hub once emitted keep working. users and organizations land on their object routes (sys_user, the sys_organization list — the #3655 map). roles and positions land on the Setup metadata catalog at /apps/setup/metadata/position?scope=environment (the ADR-0090 rename, one surface under two vocabularies), and permissions at /apps/setup/metadata/permission?scope=environment (decision A: rules-and-assignments is layer 2) — the catalog, not the sys_position / sys_permission_set object routes, since objectui#7611 (ADR-0131 D3: positions and permission sets are registry items)", "oracle": "network", - "verify": "the navigation trace per legacy URL shows the redirect target object route; the landing renders", + "verify": "the navigation trace per legacy URL shows the redirect target: the object route for users and organizations, the …/metadata/TYPE?scope=environment catalog route for roles, positions and permissions; the landing renders. A roles, positions or permissions URL landing on the sys_position / sys_permission_set object route is the pre-objectui#7611 shape, so suspect the stale-console-bundle trap before scoring it", "evidence": "the five redirect traces + landings" }, { @@ -2369,7 +2369,7 @@ "source": [ "objectui packages/app-shell/src/layout/UnifiedSidebar.tsx (homeNavigation: base Home + Documentation, and the isWorkspaceAdmin-gated adminItems cluster with the #3590 /system targeting note and the #3660/#3739 canonical-metadata-route notes — the target list this item walks; mounted only by ConsoleLayout, which sets the app context) + packages/app-shell/src/console/home/HomeLayout.tsx (/home deliberately omits the sidebar)", "objectui apps/console/src/AppContent.tsx (SystemLandingRedirect: the bare …/system landing forwards to …/system/settings; SystemHubPage — its cards and count badges — retired by objectui#3743, carried out in objectui#10507) + apps/console/src/pages/settings/SettingsHub.tsx (the registry-driven hub: the settings manifests the server returns for this user)", - "objectui apps/console/src/AppContent.tsx (SystemObjectRedirect — the five legacy URLs, the #3655 object map with the ADR-0090 roles→sys_position note, and the measured pre-fix landings) + the exported systemRoutes fragment", + "objectui apps/console/src/AppContent.tsx (SystemObjectRedirect — users and organizations, the #3655 object map and the measured pre-fix landings; SystemCatalogRedirect — roles, positions and permissions onto the Setup catalog in its environment scope since objectui#7611, with the ADR-0090 roles→position note and decision A's permissions→permission note) + packages/app-shell/src/views/metadata-admin/catalog-scope.ts (ENVIRONMENT_SCOPE_QUERY, scope=environment) + the exported systemRoutes fragment", "objectui apps/console/src/__tests__/AppContent.systemHubRoutes.test.tsx + AppContent.legacyRedirects.test.tsx (the objectui-side pins — see knownGaps)", "platform-core.builtin-apps-nav-render (owns the server-side Setup refusal for the member — this item owns the shell-hard-coded home cluster, which that item does not touch)" ], @@ -2385,6 +2385,12 @@ "date": "2026-10-05", "change": "whole-item revision for three clauses #21782 failed (VF4). Clause 2 (stale): objectui 9eea12a2b (objectui#10507, fixing objectui#3743) deleted SystemHubPage before 17.6.0, and /apps/:app/system is now SystemLandingRedirect, one hop to /system/settings, so the clause walks the settings hub (verifier's wording). Clause 4 (retired): the hub count badges went with SystemHubPage, so the clause is dropped, with its badge-honesty negative and the two badge steps. Clause 5 (assertion defect): no /home sidebar exists for any persona, because HomeLayout omits it and UnifiedSidebar is mounted only by ConsoleLayout, which forces the app context; the member clause now asserts that no Administration entry is offered anywhere (verifier's wording). The verifier also found that the Administration cluster itself has no stable rendering surface, so steps 1-3 and 7, clause 1 and negatives 1 and 4 (now 1 and 3) walk the 9 adminItems as target URLs. A knownGap records why. The member persona now reads invite + sign-up (stock invite_only). Re-read at objectui 9dfaca6543: unchanged from the run's 2e818d0b51ec", "ref": "#21797" + }, + { + "revision": 3, + "date": "2026-10-13", + "change": "clause 3, acceptance[2] (the legacy-redirect clause) re-pointed, with step 4, the title and the AppContent source note (stale spec, #23104 A2). Since objectui#7611, roles, positions and permissions no longer land on the sys_position / sys_permission_set object routes: SystemCatalogRedirect forwards them to the Setup metadata catalog in its environment scope (roles and positions to /apps/setup/metadata/position?scope=environment, permissions to /apps/setup/metadata/permission?scope=environment), because ADR-0131 D3 makes positions and permission sets registry items. users and organizations still use SystemObjectRedirect and are unchanged. Read at objectui 1fb1ced36243 (this repo's .objectui-sha): AppContent.tsx's systemRoutes, and the 'the catalog entries reach the Setup catalog (objectui#7611)' block of AppContent.systemHubRoutes.test.tsx. Both files are byte-identical at 7578323a544e", + "ref": "#23121" } ] }, From 75c354754744b998ffc24b0c68ffe3d854cf0456 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 02:43:58 +0000 Subject: [PATCH 2/4] docs(qa): auth-method-matrix and self-signup-gate re-pointed to the shipped delete-user and sign-up-offer rules auth-method-matrix: delete-user is never 2xx, authed 404 and anonymous 401 (the wiring test is the oracle). self-signup-gate: under stock invite_only the console offers the register form only to an invitation redirect or a deployment with no owner, and the by-invitation notice to everyone else. Claude-Session: https://claude.ai/code/session_012dGTuJen61ECNHXYXKChoC Co-authored-by: Claude --- .../areas/identity-auth.json | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index dbd2dedd94..e26992e184 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -155,7 +155,7 @@ "title": "Every supported auth method signs in when enabled, is absent when disabled, and is advertised exactly as configured", "since": "v16", "status": "active", - "revision": 6, + "revision": 7, "priority": "P1", "surface": "mixed", "personas": ["anonymous visitor", "provisioned user per method", "admin (for env configuration)"], @@ -181,7 +181,7 @@ "device authorization: with deviceAuthorization on, drive the RFC 8628 flow (POST /device/code, approve via /device/approve in a signed-in browser, poll /device/token) against the LIVE server's routes, recording which path spelling the server actually serves", "2FA: with twoFactor on, enable it for a user (sys_user enable_two_factor action, gated on features.twoFactor), sign in, and verify the server-driven challenge (ADR-0069) interrupts before a session is granted", "discovery documents: GET /.well-known/openid-configuration and GET /.well-known/oauth-authorization-server (both mounted at the APP ROOT, not under /api/v1/auth — RFC 8414 / OIDC require it, auth-plugin.ts) and record the issuer + advertised endpoints", - "self-service identity mutations: POST /api/v1/auth/change-email (authed) and POST /api/v1/auth/delete-user (authed) — capture each, plus the anonymous forge of both. delete-user is deliberately unwired (auth-route-ledger disposition 'disabled': user.deleteUser is not configured, maintainer ruling on #7735), so expect 404 to every caller", + "self-service identity mutations: POST /api/v1/auth/change-email (authed) and POST /api/v1/auth/delete-user (authed) — capture each, plus the anonymous forge of both. delete-user is deliberately unwired (auth-route-ledger disposition 'disabled': user.deleteUser is not configured, maintainer ruling on #7735), so expect 404 for the signed-in caller and 401 for the anonymous forge (the route is mounted and session-guarded, so the session check answers first) — never 2xx", "for each variant: re-boot with the method OFF, then (a) GET /auth/config and confirm the advertisement is gone, (b) screenshot the login page and confirm the affordance is gone, (c) fire the method's endpoint anyway and confirm a server-side refusal", "record the per-variant verdict table (on-side result, off-side result, advertisement parity)" ], @@ -241,10 +241,10 @@ "evidence": "the two discovery-document responses + the jwks cross-check" }, { - "clause": "POST /auth/change-email succeeds for the signed-in user and answers 401 to the anonymous forge; POST /auth/delete-user is deliberately unwired (ledger disposition 'disabled') and answers 404 to every caller, authed or not.", + "clause": "POST /auth/change-email succeeds for the signed-in user and answers 401 to the anonymous forge; POST /auth/delete-user is deliberately unwired (ledger disposition 'disabled') and is never 2xx: the signed-in caller gets 404 (the capability switch — user.deleteUser is unconfigured) and the anonymous forge gets 401 UNAUTHORIZED (the route is mounted and session-guarded, so the session check answers first).", "oracle": "api", - "verify": "the authed change-email returns 2xx (and, if verification is required, does not flip the address until the confirmation link is followed) and the anonymous forge answers 401; delete-user answers 404 both authed and anonymous (as does its GET /delete-user/callback half) — the ledger books it 'disabled' because user.deleteUser is deliberately unconfigured. A 2xx from delete-user, for anyone, is the FAIL", - "evidence": "the change-email responses (authed + anonymous) + the two delete-user 404s" + "verify": "the authed change-email returns 2xx (and, if verification is required, does not flip the address until the confirmation link is followed) and the anonymous forge answers 401; delete-user answers 404 to the signed-in caller (an empty body — better-auth's disabled branch carries no code) and 401 to the anonymous forge, and the account survives; its GET /delete-user/callback half answers 404 NOT_FOUND to a signed-in caller. The anonymous 401 beside the authed 404 is what tells the capability switch apart from an unmounted route. Pinned by packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts (the '#7735 — POST /delete-user stays unwired, and the ledger says so' block: anonymous 401, authed 404, callback 404, nothing deleted) — cite the pin per rule 6. The ledger books the route 'disabled' because user.deleteUser is deliberately unconfigured. A 2xx from delete-user, for anyone, is the FAIL", + "evidence": "the change-email responses (authed + anonymous) + the delete-user responses (authed 404, anonymous 401) + the callback 404, or the cited pin output" } ], "negative": [ @@ -281,7 +281,8 @@ { "revision": 3, "date": "2026-08-20", "change": "scoped scan-functionality (扫描功能) sweep: the 2FA clause asserted the interrupt but cited no pin — added packages/qa/dogfood/test/two-factor-lockout.dogfood.test.ts to source and noted in the clause verify + variant row that cookie-lane completion is pinned end-to-end by that test (cite per rule 6 instead of re-deriving). Enrollment lifecycle around the gate now owned by the four new identity-auth.two-factor-* items. No restructuring", "ref": "claude/new-session-0pv25p" }, { "revision": 4, "date": "2026-10-05", "change": "clause 10 and step 9 re-pointed: the delete-user half only (stale, #21784 VF4). plugin-auth's auth-route-ledger books POST /api/v1/auth/delete-user with disposition 'disabled': user.deleteUser is deliberately unconfigured (auth-manager), per the maintainer ruling of 2026-08-12 on #7735, so it answers 404 to every caller. The clause dates from revision 1 (2026-08-07) and predates the ruling. The verifier's wording is used. The change-email half is unchanged", "ref": "#21797" }, { "revision": 5, "date": "2026-10-05", "change": "clause 4, its negative, step 4, the fixtures row and the phone-OTP variant re-pointed from NOT_SUPPORTED to the shipped refusal: with no deliverable SMS service, send-otp answers 400 with code SMS_SERVICE_REQUIRED (registered for @objectstack/plugin-auth in the ADR-0112 ledger) instead of a 500 with an empty body (#21784 A4). A bare 500 is now named a FAIL. The clause verify cites the door pin", "ref": "#21793" }, - { "revision": 6, "date": "2026-10-06", "change": "partial-coverage gap of QA run #21784 parked on #22017 (row 20). A1's SSO / device variants were blocked (no switch on the dev → serve path) and N3 was blocked(dependency, objectui#4179). Disposition: recorded knownGap. Verified: serve.ts injects AuthPlugin with four env-mapped plugins and no deviceAuthorization or oidcProviders; SSO has the OS_SSO_ENABLED boot switch but needs a registered provider behind a reachable IdP. A knownGap names that environment, and that CI and the QA container lack it. objectui#4179 closed not_planned on 2026-09-24, so the magic-link / passkey knownGap and negative 3 now say so", "ref": "#22017" } + { "revision": 6, "date": "2026-10-06", "change": "partial-coverage gap of QA run #21784 parked on #22017 (row 20). A1's SSO / device variants were blocked (no switch on the dev → serve path) and N3 was blocked(dependency, objectui#4179). Disposition: recorded knownGap. Verified: serve.ts injects AuthPlugin with four env-mapped plugins and no deviceAuthorization or oidcProviders; SSO has the OS_SSO_ENABLED boot switch but needs a registered provider behind a reachable IdP. A knownGap names that environment, and that CI and the QA container lack it. objectui#4179 closed not_planned on 2026-09-24, so the magic-link / passkey knownGap and negative 3 now say so", "ref": "#22017" }, + { "revision": 7, "date": "2026-10-13", "change": "clause 10, acceptance[9] (the delete-user half) and step 9 re-worded (assertion defect, #23104 A9). Revision 4 said delete-user answers 404 to every caller, authed or not. The route is mounted and session-guarded, so the anonymous forge answers 401 UNAUTHORIZED and only a signed-in caller reaches the capability switch's 404. No caller gets 2xx, and the disposition 'disabled' is unchanged. The oracle is packages/plugins/plugin-auth/src/change-email-delete-user-wiring.test.ts, whose '#7735' delete-user block pins anonymous 401, authed 404 and the callback 404, and says the 401-beside-404 pair is how the switch is told apart from an unmounted route. The change-email half is unchanged", "ref": "#23121" } ] }, { @@ -1730,10 +1731,10 @@ }, { "id": "identity-auth.self-signup-gate", - "title": "Self-signup gate: the default audience posture (invite_only) refuses at the server with a LOUD code, a pending invitation carves through, and OS_DISABLE_SIGNUP bounces the UI AND the endpoint — the register form's presence is deliberate, never the gate", + "title": "Self-signup gate: the default audience posture (invite_only) refuses at the server with a LOUD code, a pending invitation carves through, and OS_DISABLE_SIGNUP bounces the UI AND the endpoint — the console offers the register form only to a visitor the server would admit (an invitation redirect, or a deployment with no owner) and explains the by-invitation rule to everyone else; the form is never the gate", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": ["anonymous uninvited registrant", "an invited email's registrant", "org admin (mints the invitation)", "env owner (sets OS_DISABLE_SIGNUP)"], @@ -1751,8 +1752,8 @@ ] }, "steps": [ - "stock boot: GET /api/v1/auth/config and record features.audiencePosture ('invite_only') and emailPassword.disableSignUp (false) — then confirm the login page still offers the register link and /register still renders the form. That presence is DELIBERATE: the posture does not force disableSignUp, because hiding the form would dead-end invited users (getPublicConfig comment, auth-manager.ts)", - "submit the register form (and separately POST /api/v1/auth/sign-up/email directly) for an UNINVITED address: capture the 403 with code SELF_REGISTRATION_CLOSED — a loud, deterministic refusal, NOT a synthetic 200. The sign-up route raises the audience refusal BEFORE better-auth's anti-enumeration shield can swallow a creation-seam 403 into a fake success (auth-manager.ts — the measured black-hole this ordering exists to prevent)", + "stock boot (the seeded admin exists, so the deployment has an owner): GET /api/v1/auth/config and record features.audiencePosture ('invite_only') and emailPassword.disableSignUp (false) — then confirm the login page offers NO generic sign-up link and /register renders the by-invitation notice (data-testid register-by-invitation: 'Self-registration is not open on this environment. Ask an administrator for an invitation.') with NO form. The form is offered only where the server would admit the visitor: a visitor bounced from an invitation (/login?redirect=/accept-invitation/ID, which the login page forwards to /register) or a deployment with no owner yet (GET /api/v1/auth/bootstrap-status answers hasOwner false) — objectui decideSignUpOffer (objectui#11691, objectui#11705). The server side is unchanged and DELIBERATE: the posture does not force disableSignUp, because hiding the form outright would dead-end invited users (getPublicConfig comment, auth-manager.ts); the console reads both keys instead", + "POST /api/v1/auth/sign-up/email directly for an UNINVITED address (the stock /register offers an uninvited visitor no form — step 1): capture the 403 with code SELF_REGISTRATION_CLOSED — a loud, deterministic refusal, NOT a synthetic 200. The sign-up route raises the audience refusal BEFORE better-auth's anti-enumeration shield can swallow a creation-seam 403 into a fake success (auth-manager.ts — the measured black-hole this ordering exists to prevent)", "confirm no sys_user row was created for the refused address (admin read)", "carve-out: as admin mint an invitation for a fresh email, then sign up with EXACTLY that email on the same stock boot — admitted (a pending, unexpired sys_invitation trumps the posture; audience-posture.ts)", "disabled boot (OS_DISABLE_SIGNUP=true): GET /auth/config shows emailPassword.disableSignUp true; navigating to /register bounces to /login preserving ?redirect (RegisterPage's probe, apps/console/src/pages/auth/RegisterPage.tsx) and the login card withholds the register link (LoginPage registerUrl passed undefined, LoginPage.tsx)", @@ -1796,7 +1797,7 @@ "an uninvited sign-up CREATING a row under invite_only is an audience-wall breach — apply RUNNER rule 7 before acting; publication rides rule 2's auth carve-out", "UI-only enforcement (bounce and hidden link present, endpoint 2xx) is a FAIL — courtesy is not the gate", "refusing an INVITED registrant on the closed posture is the invite dead-end class the carve-out exists for — FAIL", - "the register affordance disappearing on the stock invite_only boot would strand invited users — a 'fix' in the wrong direction; the deliberate form-stays-rendered shape is part of the contract" + "under stock invite_only on a deployment with an owner, a generic 'Sign up' link on the login page, or the register form on /register for a visitor who did not come from an invitation, is a FAIL: that is the pre-objectui#11691 shape, which offered the form and then refused the finished form with 403 SELF_REGISTRATION_CLOSED. Equally, no form for an /accept-invitation/ID redirect, or on a deployment with no owner yet, strands the people the carve-out and the bootstrap exist to admit — FAIL" ], "variants": [ "invite_only (stock default; refuse SELF_REGISTRATION_CLOSED, invitation carve-out admits — audience-posture.ts,398-402)", @@ -1812,11 +1813,13 @@ "packages/plugins/plugin-auth/src/auth-manager.ts#readDisableSignUpEnv (readDisableSignUpEnv: OS_AUTH_SIGNUP_ENABLED wins, then OS_DISABLE_SIGNUP) + (effective disableSignUp: ssoOnly forces true, else env ?? config) + (the sign-up route raises the audience refusal BEFORE the anti-enumeration shield — measured swallow documented in place) +,5040 (getPublicConfig: disableSignUp NOT forced by posture, features.audiencePosture advertised)", "packages/spec/src/system/auth-config.zod.ts#AUDIENCE_POSTURES (AUDIENCE_POSTURES const — invite_only/email_domain/open, the named enum backing posture z.enum) + (AudienceConfigSchema on AuthConfigSchema — the stack-config door; the auth settings namespace is the second door, through the console and the OS_AUTH_AUDIENCE_* env overrides)", "objectui apps/console/src/pages/auth/RegisterPage.tsx,69-88 (probe /auth/config, bounce to /login when disableSignUp; server-side gate named as the source of truth in its own header)", - "objectui apps/console/src/pages/auth/LoginPage.tsx,136,298 (signUpDisabled from /auth/config; registerUrl withheld when disabled)" + "objectui apps/console/src/pages/auth/LoginPage.tsx,136,298 (signUpDisabled from /auth/config; registerUrl withheld when disabled)", + "objectui packages/app-shell/src/console/auth/signUpOffer.ts (decideSignUpOffer: form / by-invitation / closed / pending from disableSignUp, features.audiencePosture, the invitation redirect and the bootstrap probe — the one decision the console's /login and /register and the package's DefaultLoginPage / DefaultRegisterPage share, objectui#11691 / objectui#11705) + apps/console/src/pages/auth/RegisterPage.tsx (the register-by-invitation notice) + apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx (the objectui-side pins)" ], "history": [ { "revision": 1, "date": "2026-08-30", "change": "authored in the 2026-08-30 coverage sweep (angle 4). CORRECTED against source from the register's hypothesis 'default boot sign-up completes': since #11739 (epic #11723, ruling 2026-08-24) the undeclared audience resolves to invite_only, so a stock-boot uninvited self-signup is REFUSED 403 SELF_REGISTRATION_CLOSED — the item encodes the posture matrix (enumSource-pinned on AUDIENCE_POSTURES), the pre-anti-enumeration-shield loud-refusal ordering, the invitation carve-out, the deliberate form-stays-rendered shape, and the OS_DISABLE_SIGNUP env gate on both the UI and the endpoint", "ref": "#sweep-2026-08-30" }, - { "revision": 2, "date": "2026-09-28", "change": "restated two knownGaps, the open variant and one source note to the shipped rule. Verification: email_domain always forces it on; open forces it on unless the deployment declares it off (OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false, or emailAndPassword.requireEmailVerification: false in the stack config), and a false stored only through the settings console is refused under open — the BOUNDARY gap said every permitting posture forces it. Audience declaration: it is not config-only — the auth settings namespace carries it, through the console and the OS_AUTH_AUDIENCE_* env overrides; the gap and the source note said no env knob. Steps, acceptance clauses, oracles and negatives are unchanged", "ref": "#20413" } + { "revision": 2, "date": "2026-09-28", "change": "restated two knownGaps, the open variant and one source note to the shipped rule. Verification: email_domain always forces it on; open forces it on unless the deployment declares it off (OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false, or emailAndPassword.requireEmailVerification: false in the stack config), and a false stored only through the settings console is refused under open — the BOUNDARY gap said every permitting posture forces it. Audience declaration: it is not config-only — the auth settings namespace carries it, through the console and the OS_AUTH_AUDIENCE_* env overrides; the gap and the source note said no env knob. Steps, acceptance clauses, oracles and negatives are unchanged", "ref": "#20413" }, + { "revision": 3, "date": "2026-10-13", "change": "negative 5, negative[4], re-pointed with step 1, step 2's UI half, the title and a new objectui source note (stale spec, #23104 N5). The item pinned that under stock invite_only the login page offers the register link and /register renders the form. Since objectui#11691 / objectui#11705 the console offers the form only where the server would admit the visitor: decideSignUpOffer reads features.audiencePosture beside disableSignUp, and offers the form under invite_only only to an /accept-invitation/ID redirect or on a deployment with no owner (bootstrap-status hasOwner false). Everyone else gets no sign-up link on /login and the by-invitation notice with no form on /register. The negative now fails the old shape (form offered, then refused 403 SELF_REGISTRATION_CLOSED) and still fails an invitee left without a form. Step 2 keeps its direct POST, since the stock /register gives an uninvited visitor no form to submit. The server rule is unchanged (getPublicConfig still does not force disableSignUp from the posture). Read at objectui 1fb1ced36243 (this repo's .objectui-sha): signUpOffer.ts, RegisterPage.tsx, LoginPage.tsx and signUpFollowsPosture-11691.test.tsx, byte-identical at 7578323a544e. acceptance[2]'s closing phrase, which says the form stays rendered under the closed posture, is left as written: it is true for an invitee, and that clause is outside this revision", "ref": "#23121" } ] }, { From b6fbd0aabddde5d93409948d3addab7172793656 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 02:45:43 +0000 Subject: [PATCH 3/4] docs(qa): owd-save-gate re-points the packaged-object clause to the managed-content seal A widening PUT on a packaged object answers 403 NOT_OVERRIDABLE from the package door (ADR-0131 D6), which runs ahead of R1 on every topology; R1 keeps its unit pins as its oracle, and no fixture package is built for it. Claude-Session: https://claude.ai/code/session_012dGTuJen61ECNHXYXKChoC Co-authored-by: Claude --- .../areas/access-security.json | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index e379a6cbc9..cd548be322 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -1318,10 +1318,10 @@ }, { "id": "access-security.owd-save-gate", - "title": "OWD save gate: external ≤ internal on every object write, and a packaged object's OWD can only be tightened at runtime — Studio inline AND server-side", + "title": "OWD save gate: external ≤ internal on every object write, and a packaged object's OWD cannot be changed at runtime — the managed-content seal refuses the write before any OWD rule judges it — Studio inline AND server-side", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": [ @@ -1335,8 +1335,8 @@ "the Studio object designer Settings tab (objectui ObjectSettingsPanel / PackageOwdOverviewPanel), which computes isExternalWider inline" ], "knownGaps": [ - "runtime EVALUATION of externalSharingModel against external principals is liveness `planned` (#2696) — this item tests the AUTHORING/save gate (external ≤ internal at write; packaged tighten-only), NOT external-principal enforcement; do not tick an external-principal read/write result", - "R1 (owd_widening_forbidden) fires on a STOCK deploy too — the escape hatch is NOT what reaches it. What selects the answering layer is the DIRECTION OF THE SUBMITTED BODY, not the deploy posture (measured on stock showcase, #9477): saveMetaItem runs the plugin-security authoring gate BEFORE the overlay/package doors on the host-config kernel the showcase boots (environmentId undefined, so protocol.ts's env-partitioned NOT_OVERRIDABLE branch is skipped), so a WIDENING body meets R1 first and always answers 403 owd_widening_forbidden — hatch open or closed, ?package= named or not. A body that does NOT widen passes R1 and only then meets the overlay door: 403 NOT_OVERRIDABLE (no ?package=) or 403 ITEM_LOCKED (?package= names the read-only base) on stock, and 2xx under OS_METADATA_WRITABLE=object. ⚠ WRITABLE_PACKAGE_REQUIRED was observed on NONE of those six legs — do not expect it here. The run must still record WHICH layer answered the 4xx, and must read the code from `declaredCode`: the dispatcher maps this refusal to `code: PERMISSION_DENIED` on the wire and carries the ledgered spelling in `declaredCode: owd_widening_forbidden`, so a check keyed on `code` alone MISSES it" + "runtime EVALUATION of externalSharingModel against external principals is liveness `planned` (#2696) — this item tests the AUTHORING/save gate (external ≤ internal at write; a packaged object sealed against runtime change), NOT external-principal enforcement; do not tick an external-principal read/write result", + "R1 (owd_widening_forbidden) is UNREACHABLE through the metadata door on a stock deploy, and with OS_METADATA_WRITABLE=object too. saveMetaItem asks the package door (refusePackagedBaseOverride, predicate isSealedManagedItem: the item is artifact-backed and `object` opens no overlay channel) on every kernel topology, ahead of the runtime lint door and the plugin-security authoring gate (#22338). Since ADR-0131 D6 (#22401) the escape hatch no longer opens an item a managed package ships. So any OWD write onto showcase_announcement is refused 403 NOT_OVERRIDABLE (no ?package=) or 403 ITEM_LOCKED (?package= names the read-only base), whatever its direction: widening or tightening, hatch open or shut. The revision-3 reading, measured at #9477, no longer holds: a widening body met R1 first and answered 403 PERMISSION_DENIED with declaredCode owd_widening_forbidden, and a tightened control passed R1 and met NOT_OVERRIDABLE, or 2xx under OS_METADATA_WRITABLE=object. R1 keeps its oracle in unit pins: packages/plugins/plugin-security/src/object-posture-gate.test.ts for its verdict, and packages/rest/src/meta-object-owd-gate.test.ts for the door order that now puts the seal in front of it. ⛔ Do not build a fixture package only to reach R1 live (#23121 triage). If a run does see the R1 refusal on some other door, read the code from `declaredCode` (owd_widening_forbidden), not `code`: the wire `code` is PERMISSION_DENIED" ] }, "steps": [ @@ -1344,7 +1344,7 @@ "Studio inline (browser): open the object designer Settings for a writable/draft object; set sharingModel=private and externalSharingModel=public_read; screenshot — the panel must flag the D11 external-wider violation inline (owd-sharing.ts isExternalWider) and not offer a clean save", "server reject: PUT /api/v1/meta/object/qa_owd_probe with sharingModel:'private' + externalSharingModel:'public_read' — capture the 4xx + error code. ⚠️ Drive this at the ACTIVE-state door, NOT ?mode=draft: the D11 refusal is raised by the runtime lint block (validateSecurityPosture, rule security-external-wider-than-internal), and draft saves deliberately defer it to the draft→active promotion gate (#4463 D1), so a ?mode=draft PUT is ADMITTED and the clause cannot be observed there", "server admit: PUT the same object with externalSharingModel:'private' (≤ private) — capture the 2xx (the gate orders, it does not blanket-deny)", - "packaged tighten (R1): PUT /api/v1/meta/object/showcase_announcement widening its shipped sharingModel public_read→public_read_write (keep externalSharingModel:'private' so the D11 lint is not tripped instead) — capture the 4xx AND its code. Expect 403 with `declaredCode: owd_widening_forbidden` (wire `code` is PERMISSION_DENIED) on a STOCK deploy, no escape hatch needed. Control leg, to prove the probe reached R1 rather than a blanket deny: re-PUT the same body TIGHTENED (public_read→private) — R1 passes it and the next door answers 403 NOT_OVERRIDABLE (or ITEM_LOCKED with ?package=com.example.showcase), 2xx under OS_METADATA_WRITABLE=object", + "packaged object (the seal): PUT /api/v1/meta/object/showcase_announcement widening its shipped sharingModel public_read→public_read_write (keep externalSharingModel:'private' so the D11 lint is not the door under test). Capture the 4xx AND its code. Expect 403 NOT_OVERRIDABLE from the managed-content seal (saveMetaItem's package door, ADR-0131 D6), on a stock deploy and under OS_METADATA_WRITABLE=object alike. With ?package=com.example.showcase the same door answers 403 ITEM_LOCKED. Control leg, to show that the seal answers and not a direction-keyed gate: re-PUT the same body TIGHTENED (public_read→private). It is refused the same way, because a managed item is sealed whatever the body says. R1 (owd_widening_forbidden) is not reached on this door; clause 4 names its unit pins", "GET /api/v1/meta/object/showcase_announcement before and after the denied PUT — confirm byte-identical", "cross-check the shipped declarations: GET /api/v1/meta/object/showcase_account and showcase_announcement — both satisfy external ≤ internal" ], @@ -1368,10 +1368,10 @@ "evidence": "the PUT trace + read" }, { - "clause": "a packaged object's OWD cannot be widened at runtime: the widening PUT on showcase_announcement answers 4xx with a ledgered code, recorded with WHICH layer answered. On a stock deploy that is 403 owd_widening_forbidden from the plugin-security authoring gate — the gate runs ahead of the overlay/package doors here, so the escape hatch is not what reaches it (#9477)", + "clause": "a packaged object's OWD cannot be changed at runtime: the widening PUT on showcase_announcement is refused 403 with a ledgered code, recorded with WHICH layer answered. On a stock deploy that is 403 NOT_OVERRIDABLE from the managed-content seal, saveMetaItem's package door (ADR-0131 D6, #22401). It answers ahead of every check that judges the body, on every kernel topology (#22338), and OS_METADATA_WRITABLE does not open it. So the plugin-security R1 gate (owd_widening_forbidden) is unreachable here on stock, and R1's oracle is its unit pins, not this run", "oracle": "api", - "verify": "PUT /api/v1/meta/object/showcase_announcement (widened) status >=400 and the ledgered code is owd_widening_forbidden. ⚠ read it from `declaredCode`, not `code`: the dispatcher answers `{code: 'PERMISSION_DENIED', declaredCode: 'owd_widening_forbidden'}` on the live REST route, so a check keyed on `code` alone misses the gate it is looking for (measured #9477; note the unit pin packages/rest/src/meta-object-owd-gate.test.ts reads the bare spelling off `code`, which is the in-process shape, not the wire shape)", - "evidence": "the PUT trace + the code + the answering-layer note" + "verify": "PUT /api/v1/meta/object/showcase_announcement (widened, no ?package=) answers 403 with code NOT_OVERRIDABLE and no declaredCode; with ?package=com.example.showcase, which names the read-only base, the same door answers 403 ITEM_LOCKED. Either way the answering layer is the package door, not R1. The door order is pinned by packages/rest/src/meta-object-owd-gate.test.ts (the '[#7674] R1 owd_widening_forbidden through PUT /api/v1/meta/object/:name' block: a widening overlay of a packaged object answers 403 NOT_OVERRIDABLE with no declaredCode and stores nothing, hatch open). R1's own verdict is pinned in-process by packages/plugins/plugin-security/src/object-posture-gate.test.ts (the 'R1 — env-tighten-only over a packaged declaration (ADR-0086 D1)' block). Cite both pins per rule 6 for R1; do not build a fixture package to reach it live", + "evidence": "the PUT trace + the code + the answering-layer note (+ the two cited pin outputs for R1)" }, { "clause": "the denied widening is inert: GET /api/v1/meta/object/showcase_announcement is byte-identical before and after the refused PUT", @@ -1393,7 +1393,7 @@ ], "variants": [ "R2 external ≤ internal (authoring gate, all object writes)", - "R1 env-tighten-only (packaged/artifact-backed objects)" + "the managed-content seal (packaged/artifact-backed objects: 403 NOT_OVERRIDABLE, or ITEM_LOCKED with ?package=, ahead of R1); R1 env-tighten-only is unreachable on this door and unit-pinned" ], "traps": [ "stale-console-bundle", @@ -1404,7 +1404,9 @@ "packages/lint/src/validate-security-posture.ts#OWD_WIDTH (OWD_WIDTH + SECURITY_EXTERNAL_WIDER lint parity)", "objectui packages/app-shell/src/views/studio-design/owd-sharing.ts (isExternalWider) + PackageOwdOverviewPanel.tsx + ObjectSettingsPanel.tsx", "examples/app-showcase/src/data/objects/announcement.object.ts#externalSharingModel + account.object.ts (externalSharingModel declarations)", - "ADR-0090 D11, ADR-0086 D1, ADR-0124 D1, #3050, objectui#2508" + "packages/metadata-protocol/src/protocol.ts#refusePackagedBaseOverride (saveMetaItem's package door — the managed-content seal: isSealedManagedItem, 403 NOT_OVERRIDABLE, or ITEM_LOCKED for a named read-only base; asked on every kernel topology ahead of the body checks since #22338, and not opened by OS_METADATA_WRITABLE since ADR-0131 D6, #22401)", + "packages/rest/src/meta-object-owd-gate.test.ts (the door-order pin: a packaged object's overlay is refused by the package door before the lint door and R1, hatch open) + packages/plugins/plugin-security/src/object-posture-gate.test.ts (R1's in-process unit pins)", + "ADR-0090 D11, ADR-0086 D1, ADR-0124 D1, ADR-0131 D6, #3050, objectui#2508" ], "history": [ { @@ -1414,7 +1416,8 @@ "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-08-18", "change": "corrected clauses 1-2 and steps 2-3 for the #8310 retirement (maintainer ruling 2026-08-13). The item tested R2 owd_external_wider / 403 at the ?mode=draft door; R2 is RETIRED from object-posture-gate.ts as a duplicate of the runtime lint door, whose refusal is 422 INVALID_METADATA rule security-external-wider-than-internal raised at the ACTIVE-state door — and draft saves deliberately defer to the draft-to-active promotion gate (#4463 D1), so the clause was unobservable where it was pointed (#9453 CF-2). ⚠️ #9453 CF-3 (that the R1 knownGap mispredicts which layer answers) was NOT applied: object-posture-gate.ts states verbatim that R1 applies only to overlay writes over an artifact-backed object and that 'the default deploy already 403s these before this gate runs' (if (!ctx.isArtifactBacked) return), which is what the knownGap and clause 4 already say", "ref": "#9386" }, - { "revision": 3, "date": "2026-08-19", "change": "the R1 knownGap, step 5 and clause 4 named the WRONG answering layer, and #9453 CF-3 was RIGHT — re-measured on a genuinely stock showcase boot and OVERTURNING the revision-2 note above (#9477). Proven stock from the server process's own /proc//environ: zero METADATA_WRITABLE occurrences among 147 vars. Target artifact-backed, proven by GET ?layers=true returning a populated `code` layer (_packageId com.example.showcase) with `overlay: null`. A widening PUT then answered 403 declaredCode owd_widening_forbidden — reproduced twice, object byte-identical after. The mechanism the old text missed: saveMetaItem runs runAuthoringGate BEFORE the overlay/package doors on the host-config kernel the showcase boots (environmentId undefined, so protocol.ts's env-partitioned NOT_OVERRIDABLE branch never arms — the #7674 note in that file says the showcase is exactly that shape). So the deploy posture does not select the layer; the body's DIRECTION does. Control leg (same boot, same object, tightened body) passed R1 and answered 403 NOT_OVERRIDABLE, i.e. the door the knownGap called 'earlier' is in fact LATER. Six legs measured (widen/tighten x stock/hatch x ?package= present/absent) and WRITABLE_PACKAGE_REQUIRED appeared on none. ⛔ Not a product defect: every widening leg refused and the object never changed — object-posture-gate.ts behaved exactly as its executable guard (if (!ctx.isArtifactBacked) return) specifies; only the prose parenthetical in that file's header, and this item, mispredicted the door order", "ref": "#9477" } + { "revision": 3, "date": "2026-08-19", "change": "the R1 knownGap, step 5 and clause 4 named the WRONG answering layer, and #9453 CF-3 was RIGHT — re-measured on a genuinely stock showcase boot and OVERTURNING the revision-2 note above (#9477). Proven stock from the server process's own /proc//environ: zero METADATA_WRITABLE occurrences among 147 vars. Target artifact-backed, proven by GET ?layers=true returning a populated `code` layer (_packageId com.example.showcase) with `overlay: null`. A widening PUT then answered 403 declaredCode owd_widening_forbidden — reproduced twice, object byte-identical after. The mechanism the old text missed: saveMetaItem runs runAuthoringGate BEFORE the overlay/package doors on the host-config kernel the showcase boots (environmentId undefined, so protocol.ts's env-partitioned NOT_OVERRIDABLE branch never arms — the #7674 note in that file says the showcase is exactly that shape). So the deploy posture does not select the layer; the body's DIRECTION does. Control leg (same boot, same object, tightened body) passed R1 and answered 403 NOT_OVERRIDABLE, i.e. the door the knownGap called 'earlier' is in fact LATER. Six legs measured (widen/tighten x stock/hatch x ?package= present/absent) and WRITABLE_PACKAGE_REQUIRED appeared on none. ⛔ Not a product defect: every widening leg refused and the object never changed — object-posture-gate.ts behaved exactly as its executable guard (if (!ctx.isArtifactBacked) return) specifies; only the prose parenthetical in that file's header, and this item, mispredicted the door order", "ref": "#9477" }, + { "revision": 4, "date": "2026-10-13", "change": "clause 4, acceptance[3], re-pointed to the managed-content seal, with step 5, the R1 knownGap, the first knownGap's packaged tighten-only parenthetical, the R1 variant, the title and two new source notes (stale spec, #23104 A3). Revision 3 measured, at #9477, that a widening PUT on showcase_announcement met R1 first and answered 403 PERMISSION_DENIED with declaredCode owd_widening_forbidden. Two later changes reorder that. #22338 made saveMetaItem ask the package door on every kernel topology, ahead of the checks that judge the body. ADR-0131 D6 (#22401, e02833c24) made the OS_METADATA_WRITABLE hatch stop opening an item a managed package ships. A packaged object, whose type opens no overlay channel, is now refused 403 NOT_OVERRIDABLE (ITEM_LOCKED with ?package=) whatever the body says, so R1 is unreachable on this door on stock. The protection holds: nothing widens. Per the #23121 triage, no fixture package is built to reach R1. Its oracle is its unit pins, named in the clause: plugin-security object-posture-gate.test.ts for the verdict, and rest meta-object-owd-gate.test.ts, whose R1 block now pins the package door answering first, hatch open", "ref": "#23121" } ] }, { From c84cc23143176cbeee6581ad31aa0bb8bac3fb06 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 02:47:28 +0000 Subject: [PATCH 4/4] docs(qa): formula-stdlib-matrix counts 29 functions and asserts the authoring door's unknown-function refusal The registration chain gained isoDate and isoDatetime; the joinNonEmpty row goes through record fields because cel-js refuses the mixed list literal; the EXPECTED-FAIL clause flips to the 422 INVALID_METADATA refusal the object save door now gives a formula calling an unregistered function. Claude-Session: https://claude.ai/code/session_012dGTuJen61ECNHXYXKChoC Co-authored-by: Claude --- .../platform-checklist/areas/api-backend.json | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/qa/platform-checklist/areas/api-backend.json b/docs/qa/platform-checklist/areas/api-backend.json index 2547205e1c..ff6b5af48e 100644 --- a/docs/qa/platform-checklist/areas/api-backend.json +++ b/docs/qa/platform-checklist/areas/api-backend.json @@ -1810,10 +1810,10 @@ }, { "id": "api-backend.formula-stdlib-matrix", - "title": "CEL formula stdlib: all 27 registered functions give known answers against the BUILT package — calendar-day timezone contract, addMonths month-end clamp, floor(-1.2)==-2, null-dropping joins, mixed int/double arithmetic — and the unregistered-function silent-null fault is RECORDED, never ticked", + "title": "CEL formula stdlib: all 29 registered functions give known answers against the BUILT package — calendar-day timezone contract, addMonths month-end clamp, floor(-1.2)==-2, null-dropping joins, mixed int/double arithmetic, isoDate/isoDatetime text — and a formula calling an unregistered function is REFUSED at the authoring door (422), never stored to read null", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "mixed", "personas": [ @@ -1827,24 +1827,24 @@ "runtime metadata authoring (PUT /meta/object on a scratch object) for the stdlib-through-the-live-door leg" ], "knownGaps": [ - "The stock formulas exercise only null-guard ternaries and arithmetic — NOT the stdlib catalog. The 27-function matrix therefore runs in a harness against the BUILT package (the same discipline as api-backend.formula-gates), and the live-door leg proves the same evaluator serves reads, not each function individually; a scratch formula field authored through the metadata channel carries two representative stdlib calls onto the live path. Record which oracle each function's verdict rests on." + "The stock formulas exercise only null-guard ternaries and arithmetic — NOT the stdlib catalog. The 29-function matrix therefore runs in a harness against the BUILT package (the same discipline as api-backend.formula-gates), and the live-door leg proves the same evaluator serves reads, not each function individually; a scratch formula field authored through the metadata channel carries two representative stdlib calls onto the live path. Record which oracle each function's verdict rests on." ] }, "steps": [ - "count the registration chain: packages/formula/src/stdlib.ts registerStdLib registers exactly 27 functions by registerFunction call — now, today, daysFromNow, daysAgo, isBlank, coalesce, trim, joinNonEmpty, daysBetween, addDays, addMonths, date, datetime, abs, round, floor, ceil, min, max, upper, lower, contains, startsWith, endsWith, matches, len, isEmpty. NOT enumSource-pinnable (a builder chain, not a named z.enum) — re-count the calls in source at run time and fail the matrix stale if the count moved; cross-check the docs catalog (content/docs/data-modeling/formulas.mdx) advertises the same set, no more, no fewer", + "count the registration chain: packages/formula/src/stdlib.ts registerStdLib registers exactly 29 bare-callable functions by chained registerFunction call — now, today, daysFromNow, daysAgo, isBlank, coalesce, trim, joinNonEmpty, daysBetween, addDays, addMonths, date, datetime, isoDate, isoDatetime, abs, round, floor, ceil, min, max, upper, lower, contains, startsWith, endsWith, matches, len, isEmpty (isoDate and isoDatetime since #22347). The receiver method `can` (current_user.can(object, verb)), which registerPermissionPredicate adds at the head of registerStdLib, is the permission predicate, not a bare-callable catalog function, and is outside this count — validate.ts's CEL_STDLIB_FUNCTIONS docblock tallies the same 29. NOT enumSource-pinnable (a builder chain, not a named z.enum) — re-count the calls in source at run time and fail the matrix stale if the count moved; cross-check the docs catalog (content/docs/data-modeling/formulas.mdx) advertises the same set, no more, no fewer", "build the workspace (or confirm dist freshness — the stale-dist trap is the known false-negative here, same as api-backend.formula-gates); the harness must import the BUILT package and record the resolved module path", - "drive every one of the 27 with a pinned now and a known-answer expectation, at minimum: floor(-1.2)==-2 and ceil(-1.2)==-1 (round toward −∞/+∞, NOT toward zero); addMonths(date('2026-01-31'), 1) == 2026-02-28T00:00:00Z (month-end clamp, never a March overflow); daysBetween(a,b) sign convention (b−a, negative when b earlier); date()/datetime() alias parity on the same ISO input; coalesce(null,'x')=='x' but coalesce('', 'x')=='' (null/undefined only); joinNonEmpty(['a', null, ' ', 'b'], '-')=='a-b' (null AND blank-after-trim dropped); isBlank vs isEmpty on null/''/[]; trim(null)==''; upper/lower/contains/startsWith/endsWith on mixed input; matches regex truthiness; len over string/list/map; min/max returning the SMALLER/LARGER OPERAND VERBATIM (type preserved, numeric comparison); abs/round; now() === the pinned instant", + "drive every one of the 29 with a pinned now and a known-answer expectation, at minimum: floor(-1.2)==-2 and ceil(-1.2)==-1 (round toward −∞/+∞, NOT toward zero); addMonths(date('2026-01-31'), 1) == 2026-02-28T00:00:00Z (month-end clamp, never a March overflow); daysBetween(a,b) sign convention (b−a, negative when b earlier); date()/datetime() alias parity on the same ISO input; isoDate(date('2026-02-28T13:45:00Z'))=='2026-02-28' and isoDatetime(date('2026-02-28T13:45:00Z'))=='2026-02-28T13:45:00.000Z' (the UTC calendar), isoDate(today()) under the boundary zone below == that zone's calendar day, and text refused (isoDate('2026-01-01') faults no matching overload — write isoDate(date(s))); coalesce(null,'x')=='x' but coalesce('', 'x')=='' (null/undefined only); joinNonEmpty([record.a, record.b, record.c, record.d], '-')=='a-b' with record {a: 'a', b: null, c: ' ', d: 'b'} (null AND blank-after-trim dropped — written through record fields, because cel-js refuses a list LITERAL that mixes string and null at type-check, 'List elements must have the same type', so the literal ['a', null, ' ', 'b'] never evaluates); isBlank vs isEmpty on null/''/[]; trim(null)==''; upper/lower/contains/startsWith/endsWith on mixed input; matches regex truthiness; len over string/list/map; min/max returning the SMALLER/LARGER OPERAND VERBATIM (type preserved, numeric comparison); abs/round; now() === the pinned instant", "calendar-day timezone contract (ADR-0053 Phase 2 D1): pin now to an instant whose calendar day DIFFERS between UTC and the reference zone (e.g. 2026-08-30T22:00:00Z with timezone Asia/Shanghai — locally already 08-31) and assert today() == 2026-08-31T00:00:00Z (the reference-tz calendar day expressed as UTC MIDNIGHT — never an instant carrying wall-clock time), daysFromNow/daysAgo offset from that same day; an unknown zone falls back to the UTC calendar day, never throws", "mixed-arithmetic overloads (registerNumericCoercions, stdlib.ts): a double-typed record field divided/multiplied by an int literal (record.amount / 100) evaluates to the promoted double instead of faulting 'no such overload' to silent null (#1928), while pure int 7/2 == 3 keeps integer-division semantics untouched", "live-door parity: GET a showcase_project row and confirm budget_remaining materializes (read-path evaluation); author a scratch object through the metadata channel whose formula field composes two stdlib calls (e.g. joinNonEmpty + upper) and read back the known answer — the stdlib reaching the LIVE read path, not just the harness", - "the expected-fail probe: author (or evaluate in-harness) a formula calling an UNregistered function (e.g. sqrt(4)); capture what actually happens at each door driven. At head the runtime contract is: a formula that compiles but does not EVALUATE yields null — applyFormulaPlan's own `r.ok ? … : null` (packages/objectql/src/engine.ts) — so the read answers 200 with the field null and no error surfaced (#3306 class; stdlib.ts documents this exact failure mode as the reason floor/ceil got registered). Record the observed shape verbatim; if an authoring-time gate refuses the formula before it is stored, record THAT as the (better) behavior instead" + "the unregistered-function probe: on a scratch object, PUT /api/v1/meta/object/NAME at the ACTIVE door (no ?mode=draft) with a formula field calling an UNregistered function (e.g. bad_f = sqrt(4)). Capture the 422 INVALID_METADATA and its expression-invalid issue, then GET the object and confirm the field was not stored. A ?mode=draft save of the same body is admitted (draft saves defer the gate to promotion, #4463 D1), and its draft→active promotion is refused the same way. What the door cannot see is unchanged underneath: a formula that compiles but faults at EVALUATE time, or a row stored before the gate, still reads null (applyFormulaPlan's `r.ok ? … : null`, packages/objectql/src/engine.ts), now with one warn line per (object, field) (#22031)" ], "acceptance": [ { - "clause": "all 27 advertised functions evaluate to their known answers against the BUILT package — the matrix is exhaustive over the hand-counted registration chain, and the docs catalog matches it in both directions (an advertised-but-unregistered function or a registered-but-undocumented one each fail this clause)", + "clause": "all 29 advertised functions evaluate to their known answers against the BUILT package — the matrix is exhaustive over the hand-counted registration chain (registerStdLib's 29 bare-callable registerFunction calls; the receiver method `can` is the permission predicate, not a catalog function), and the docs catalog matches it in both directions (an advertised-but-unregistered function or a registered-but-undocumented one each fail this clause)", "oracle": "test", "verify": "harness output: one known-answer row per function, resolved module path pointing into dist/node_modules; the source count and the docs-table cross-check recorded", - "evidence": "the 27-row harness table + the resolved path + the two counts" + "evidence": "the 29-row harness table + the resolved path + the two counts" }, { "clause": "the calendar-day trio (today/daysFromNow/daysAgo) resolves to the REFERENCE-TIMEZONE calendar day expressed as UTC midnight (ADR-0053 D1) — proven with an instant whose calendar day differs between UTC and the zone, so a UTC-only implementation cannot pass by accident; unknown zone falls back to UTC without throwing", @@ -1871,16 +1871,16 @@ "evidence": "the responses vs the expectation" }, { - "clause": "EXPECTED-FAIL — the unregistered-function fault is silent-null at head, and this clause exists to RECORD that, not to bless it: the probe's actual behavior (200, field null, no surfaced error — applyFormulaPlan's r.ok?…:null; or a louder authoring-time refusal if one has since landed) is captured verbatim. A run must NOT tick this clause green: silent-null is the standing #3306-class hazard, and the clause flips to a positive assertion only when the platform grows a loud refusal", + "clause": "a formula calling an UNREGISTERED function is refused at the authoring door, before it is stored: an active-state object save (PUT /api/v1/meta/object/NAME, no ?mode=draft) whose formula field calls sqrt(...) answers 422 INVALID_METADATA carrying the build's located finding (rule expression-invalid, at that field's expression, naming `sqrt` as a NAME fault), and nothing lands; a ?mode=draft save is admitted (#4463 D1) and its promotion is refused the same way. This is the loud refusal the earlier EXPECTED-FAIL wording waited for (#22031, fixing #22019)", "oracle": "api", - "verify": "the probe's captured behavior, compared against the engine.ts contract; any run recording 'pass' here without a platform change is a false green", - "evidence": "the captured probe trace + the run-record note naming which behavior was observed" + "verify": "the PUT answers 422 with code INVALID_METADATA and an issue whose rule is expression-invalid, located at the field's expression and naming the function; a follow-up GET /api/v1/meta/object/NAME shows the field was not stored. Pinned through the real saveMetaItem / publishMetaItem by packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts (the 'runtime authoring gate on OBJECT writes — the formula field verdict (#22019)' block: sqrt(record.amount) refused 422 on an active save and on a draft's promotion, nothing lands, floor(record.amount) still saves), and at the rule layer by packages/lint/src/runtime-gate.object-formula-writes.test.ts. A row stored BEFORE the gate still reads null on the read path, now with one warn line per (object, field) (packages/objectql/src/engine-formula-fault-log.test.ts). That is the residual, not this clause", + "evidence": "the 422 response (code + the expression-invalid issue) + the post-refusal metadata read" } ], "negative": [ "a harness that silently resolved src/ instead of dist is not-run, not pass — the resolved module path is part of the evidence (the api-backend.formula-gates discipline)", "today() returning an instant with wall-clock time (or the UTC calendar day under a non-UTC zone) breaks the one representation `record.date == today()` equality depends on — the exact drift ADR-0053 D1 exists to prevent", - "the silent-null probe ticked as a pass is a false green (the clause is expected-fail); equally, filing it as a NEW defect is wrong — it is a known standing class (#3306), recorded on the item" + "a formula calling an unregistered function that SAVES 2xx at the active door, and then reads null, is a FAIL: it is the #3306-class silent null this door now refuses (#22019); extract it. A row stored before the gate reading null, with its warn line, is the documented residual and not a new defect" ], "traps": [ "stale-dist", @@ -1888,7 +1888,9 @@ "timezone-boundary" ], "source": [ - "packages/formula/src/stdlib.ts#registerStdLib (registerStdLib — the 27 registerFunction calls; calendarDayUtc ADR-0053 D1; addMonthsUtc clamp; the floor/ceil #3306 note), (registerNumericCoercions #1928)", + "packages/formula/src/stdlib.ts#registerStdLib (registerStdLib — the 29 chained registerFunction calls, isoDate / isoDatetime since #22347; the receiver method `can`, from registerPermissionPredicate, is outside the count; calendarDayUtc ADR-0053 D1; addMonthsUtc clamp; the floor/ceil #3306 note), (registerNumericCoercions #1928)", + "packages/formula/src/validate.ts#CEL_STDLIB_FUNCTIONS (the advertised bare-callable list; its docblock tallies the 29 registerStdLib adds and why a receiver-only name such as `can` is never in it)", + "packages/lint/src/authoring-rules.ts#EXPRESSION_INVALID (the expression-invalid rule the object save door now dispatches on a formula field, #22031) + packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts (the #22019 block: sqrt(record.amount) refused 422 INVALID_METADATA on an active save and on a draft's promotion, nothing lands) + packages/lint/src/runtime-gate.object-formula-writes.test.ts + packages/objectql/src/engine-formula-fault-log.test.ts (the read-path residual: a pre-gate row still reads null, one warn line per object and field)", "packages/objectql/src/engine.ts#applyFormulaPlan (applyFormulaPlan — evaluate-fault → null, the silent-null contract; the evaluateFormulaField docblock states the compile-throws vs evaluate-nulls split)", "content/docs/data-modeling/formulas.mdx (the advertised catalog the matrix cross-checks)", "examples/app-showcase/src/data/objects/field-zoo.object.ts + project.object.ts (the stock live-leg formulas)", @@ -1902,6 +1904,12 @@ "date": "2026-08-30", "change": "new — the stdlib catalog (27 registered CEL functions) had no matrix: api-backend.formula-gates proves three runtime shapes and the build gate, and nothing asserted the advertised function set evaluates to its documented answers. Count verified by reading stdlib.ts (exactly 27 registerFunction calls — the register's '~27' resolved); the docs table (formulas.mdx) groups them into 18 rows covering the same 27. Not enum-pinnable (a registration chain, not a z.enum) — the count pin is a step-1 source re-count, stated on the item per the enumSource rules. The unregistered-function negative is authored as an expected-fail probe: engine.ts's applyFormulaPlan demonstrably nulls evaluate-faults, so a run records that behavior and must not tick it", "ref": "#sweep-2026-08-30" + }, + { + "revision": 2, + "date": "2026-10-13", + "change": "clause 1 (acceptance[0], #23104 C1) and clause 6 (acceptance[5], #23104 C6) revised, both stale spec. C1: registerStdLib now chains 29 bare-callable registerFunction calls, the 27 plus isoDate and isoDatetime (345d3f3d8, #22347), and formulas.mdx lists the same 29 in 19 rows. The title, step 1's count and list, step 3, the 27-function knownGap, clause 1's text and evidence, and the registerStdLib source note now say 29. Step 3 gains the isoDate / isoDatetime known answers. Its joinNonEmpty row was an assertion defect inside the item: cel-js refuses the literal ['a', null, ' ', 'b'] at type-check ('List elements must have the same type'), so the row now feeds the same values through record fields and expects 'a-b'. The receiver method `can` (registerPermissionPredicate, #18781) is registered at the head of registerStdLib but is not a bare-callable catalog function; step 1 and clause 1 now name it as outside the count, as validate.ts's CEL_STDLIB_FUNCTIONS docblock already does. C6: the EXPECTED-FAIL clause anticipated a louder authoring-time refusal, and that refusal has landed (f85a83b83, #22031, fixing #22019). The object save door dispatches the build's expression rule, so a formula calling sqrt(...) is refused 422 INVALID_METADATA (rule expression-invalid, a NAME fault) at the active door and on a draft's promotion, and nothing is stored. Clause 6, step 7, negative 3 and the title now assert that refusal. They cite protocol.runtime-authoring-gate.test.ts's #22019 block and the lint pin, and name the read-path residual (a pre-gate row still reads null, now with a warn line). Measured against the built packages/formula/dist at f931e765b: the literal joinNonEmpty row refused, the record-field row 'a-b', isoDate / isoDatetime as stated, sqrt(4) refused no matching overload", + "ref": "#23121" } ] },