diff --git a/docs/qa/platform-checklist/areas/dashboards.json b/docs/qa/platform-checklist/areas/dashboards.json index 332a5f2e59..62f507d6fc 100644 --- a/docs/qa/platform-checklist/areas/dashboards.json +++ b/docs/qa/platform-checklist/areas/dashboards.json @@ -280,7 +280,7 @@ "title": "Every ChartTypeSchema member renders a real chart with the correct marks and series count", "since": "v15", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "browser", "personas": [ @@ -291,7 +291,7 @@ "requires": [ "the Chart Gallery dashboard (showcase_chart_gallery) — one widget per DISTINCTLY-rendered family, bound to showcase_task_metrics / showcase_project_metrics (ADR-0021)", "a scratch draft dashboard (via PUT /api/v1/meta/dashboard/qa_single_value_gallery?mode=draft) for the four single-value synonym types the gallery deliberately does not duplicate", - "a scratch draft dashboard for the period-over-period compareTo probe (via PUT /api/v1/meta/dashboard/qa_compareto_probe?mode=draft) — NO showcase widget authors compareTo, so this is authored fresh exactly like the strict-widget probes; bind a metric/line widget to showcase_task_metrics with a time dimension (a month-bucketed created_at) and carry compareTo: { kind: 'previousPeriod' }" + "a scratch draft dashboard for the period-over-period compareTo probe (via PUT /api/v1/meta/dashboard/qa_compareto_probe?mode=draft) — NO showcase widget authors compareTo, so this is authored fresh exactly like the strict-widget probes. Two things the probe needs, both measured by QA run #23927: (a) a DATED WINDOW — compareTo shifts the selection's date range, so the dashboard declares one, dateRange: { field: 'issued_on', defaultRange: 'last_90_days' }; without it the dataset query answers a loud, correct 400 'compareTo needs a dated window to shift…' (packages/services/service-analytics/src/dataset-executor.ts) instead of a render; (b) data in BOTH windows — bind a line widget on the issued_on time dimension and a metric widget, each to dataset showcase_invoice_metrics with values ['invoice_count'] and compareTo: { kind: 'previousPeriod' }. The invoice seed spreads issued_on across ~6 months (examples/app-showcase/src/data/seed/index.ts: 7 dated invoices in the last 90 days, 3 in the 90 days before), so the comparison is non-zero and differs from the base. ⛔ Not showcase_task_metrics on created_at: every seeded task is created inside the current window, so the previous period is empty and a flat-zero comparison barely discriminates" ], "knownGaps": [ "kpi / gauge / solid-gauge / bullet have NO distinct renderer today — all four route to the SAME single-value renderer as `metric` (chart.zod.ts NOTE: '`metric`/`kpi` are kept as honest single-value synonyms; `gauge`/`solid-gauge`/`bullet` render a value today and gain a dial when a gauge renderer lands'; examples/app-showcase/test/coverage.test.ts SAME_AS_METRIC = {kpi, gauge, solid-gauge, bullet}). Two consequences a run must hold BOTH of: (a) the demonstrable claim is 'renders the value, not blank', NOT 'renders a dial' — do not tick a dial that does not exist; and (b) ⚠️ the four scratch tiles are expected to be VISUALLY IDENTICAL to each other and to the metric tile. Four indistinguishable tiles is the CORRECT observation, not evidence that the renderer ignored the declared type and not a per-variant FAIL — re-confirmed on 17.1.0 (#10560). The type IS being honoured; there is simply one renderer behind five names, which is what makes them synonyms rather than families" @@ -307,7 +307,7 @@ "for table and pivot, verify real grouped tables: Projects by Account rows == distinct accounts with 3 measure columns; Tasks by Status × Priority cross-tab dimensions match distinct status × priority", "author the scratch draft dashboard with four widgets of types kpi, gauge, solid-gauge, bullet, each bound to dataset showcase_task_metrics, values ['task_count'], publish it, and load it", "verify each of the four renders the same numeric value the metric tile shows — a number, never a blank tile", - "author qa_compareto_probe with a time-dimensioned widget carrying compareTo: { kind: 'previousPeriod' }, publish it, and load it — verify a comparison series/delta actually RENDERS (not the base numbers alone with the comparison silently dropped, the pre-#5011 ADR-0021 dataset-path bug)", + "author qa_compareto_probe as fixtures.requires[2] describes (dashboard dateRange on issued_on, last_90_days; a line on issued_on and a metric, both on showcase_invoice_metrics invoice_count, both carrying compareTo: { kind: 'previousPeriod' }), publish it, and load it — verify a comparison series/delta actually RENDERS (not the base numbers alone with the comparison silently dropped, the pre-#5011 ADR-0021 dataset-path bug). Derive the expected base and comparison counts first from two literal-window reads of showcase_invoice on issued_on (the last 90 days, and the 90 days before), from the same clock", "capture the per-variant verdict table" ], "acceptance": [ @@ -356,7 +356,7 @@ { "clause": "period-over-period compareTo renders the comparison: a widget authoring compareTo: { kind: 'previousPeriod' } on the scratch draft draws a comparison series/delta beside its base measure — the ADR-0021 dataset path that used to silently DROP the string arm now carries the converged { kind, dimension? } shape (#5011)", "oracle": "screenshot", - "verify": "the qa_compareto_probe widget shows a base value AND a previous-period comparison (series/delta), not the base alone; DOM read only after the screenshot confirms render", + "verify": "the qa_compareto_probe widgets show a base value AND a previous-period comparison (series/delta), not the base alone, and the comparison matches the previous window's literal-window count (non-zero on the stock invoice seed); DOM read only after the screenshot confirms render", "evidence": "the compareTo widget screenshot" }, { @@ -430,6 +430,12 @@ "date": "2026-08-21", "change": "made the single-value-synonym carve-out say what a runner will actually SEE. The item already recorded that kpi/gauge/solid-gauge/bullet render 'the same single value as metric', which stops the wrong tick (a dial) but not the wrong FAIL: a runner putting the four scratch tiles side by side sees four indistinguishable tiles and can read that as the renderer ignoring the declared type — i.e. as the wrong-family negative two lines down. Re-confirmed at source (chart.zod.ts's synonym NOTE; coverage.test.ts's SAME_AS_METRIC = {kpi, gauge, solid-gauge, bullet}): there is ONE renderer behind five names. The knownGap now states that identical tiles are the expected observation, clause 4's verify says so at the point of scoring, and the wrong-family negative carries an explicit exemption so the two failures cannot be confused (#10236 A5, observed #10560)", "ref": "#10236" + }, + { + "revision": 5, + "date": "2026-10-16", + "change": "fixtures.requires[2], steps[9] and acceptance[7].verify re-pointed so the compareTo probe can render and discriminate (fixture gaps F1 and F2 of QA run #23927, wave #23878). F1: as written the probe declared no dateRange, and the dataset query refused it with a loud, correct 400 'compareTo needs a dated window to shift' — #23927 had to add one to get a render. F2: bound to showcase_task_metrics on created_at, the previous 90-day window held no tasks on the stock seed, so the comparison series was flat at 0. The probe now declares dateRange { field: 'issued_on', defaultRange: 'last_90_days' } and binds to showcase_invoice_metrics, whose seeded issued_on dates fall in both windows (7 and 3), and the verify text reconciles the comparison against the previous window's literal-window count. Not loosened: a comparison silently absent, or one that disagrees with the previous window's count, still FAILS", + "ref": "#24036" } ], "enumSource": { @@ -443,7 +449,7 @@ "title": "Dataset-only authoring works end to end: layout-less designer drafts save/publish, and every ReportType renders from the same semantic datasets", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "mixed", "personas": [ @@ -465,7 +471,7 @@ "open each seeded report from the Analytics nav: Hours by Status (summary), Status × Priority (matrix), Task Overview (joined), Hours by Status (Chart)", "for the summary report, compare each row's est_hours sum to a direct dataset/aggregate query for that status", "for the matrix report, verify the rows × columns cross-tab (status down, priority across) and reconcile 3 cells to the aggregate", - "for the joined report, verify BOTH blocks render (open_block with runtimeFilter done:false, done_block with done:true) and that their totals partition the task population", + "for the joined report, verify BOTH blocks render (open_block with runtimeFilter done:false, done_block with done:true) and that their runtimeFilters partition the task population. The two blocks carry DIFFERENT measures — open_block est_hours, done_block task_count (examples/app-showcase/src/ui/reports/index.ts) — so the partition is reconciled per measure through the API: for est_hours and for task_count, the done:false aggregate plus the done:true aggregate equals the unfiltered aggregate; and each block's rendered total equals its own measure under its own runtimeFilter (open_block est_hours under done:false, done_block task_count under done:true)", "for the chart report, verify the embedded DatasetReportChart plots the same measure the table shows (bar over status × est_hours, a second queryDataset call per ADR-0021)", "verify the tabular variant via the showcase_task `tabular` ListView ('Task List' nav node)", "attempt the negative: author a joined report whose blocks[] contains a block of type 'joined' and capture the rejection" @@ -502,10 +508,10 @@ "evidence": "matrix screenshot + cell comparison" }, { - "clause": "joined report renders both blocks and their runtimeFilters actually partition: open_block totals + done_block totals == unfiltered totals", + "clause": "joined report renders both blocks and their runtimeFilters actually partition the task population: each block's rendered total equals the aggregate of ITS measure under ITS runtimeFilter, and, per measure, the done:false and done:true aggregates sum to the unfiltered aggregate. The blocks carry different measures (open_block est_hours, done_block task_count), so one block's hours are never added to the other's count", "oracle": "api", - "verify": "sum comparison across the two blocks vs the unfiltered aggregate", - "evidence": "the three totals" + "verify": "(1) open_block's rendered est_hours total == SUM(estimate_hours) WHERE done = false, and done_block's rendered task_count == COUNT WHERE done = true, each from a direct aggregate taken in the same run; (2) for est_hours AND for task_count, the done:false aggregate + the done:true aggregate == the unfiltered aggregate. A block total equal to its measure's UNFILTERED aggregate (its runtimeFilter ignored) is a FAIL", + "evidence": "per measure: the two filtered aggregates and the unfiltered one, beside the two rendered block totals" }, { "clause": "the embedded report chart plots the bound dataset's measure — same numbers as the table above it", @@ -552,6 +558,12 @@ "date": "2026-08-07", "change": "new item: dataset-only authoring contract (ADR-0021) + ReportType variant matrix, pinned to the designer-roundtrip golden test", "ref": "claude/platform-test-checklist-ocwugl" + }, + { + "revision": 2, + "date": "2026-10-16", + "change": "acceptance[5] (clause, verify, evidence) and steps[6] made computable (checklist-accuracy finding C2 of QA run #23927, wave #23878). The clause asked for 'open_block totals + done_block totals == unfiltered totals', but the two blocks carry different measures — open_block est_hours, done_block task_count (examples/app-showcase/src/ui/reports/index.ts) — so the sum mixed hours with a count and could not be computed; #23927 reconciled it per measure through the API. The clause now checks each block's rendered total against its own measure under its own runtimeFilter, and the per-measure partition (done:false + done:true == unfiltered) for both measures. Not loosened: a block whose runtimeFilter is ignored still renders its measure's unfiltered total and FAILS", + "ref": "#24036" } ] }, @@ -882,7 +894,7 @@ "title": "The built-in System Overview dashboard renders as admin with LIVE sys_* counts, two widget values reconcile against direct API counts, single-environment-only widgets hide gracefully, and no widget renders blank", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "mixed", "personas": [ @@ -902,7 +914,7 @@ "steps": [ "sign in as admin, open the Setup app, and navigate to System Overview (nav_system_overview → dashboard system_overview); let the board settle and screenshot the whole dashboard BEFORE reading any DOM", "capture the dataset queries the widgets issue on load (the network trace) — each metric tile queries its sys_*_metrics dataset (count aggregate)", - "establish ground truth via the data API: a count aggregate on sys_user, and a count aggregate on sys_session restricted to LIVE sessions (revoked_at is null AND expires_at after now — the Active Sessions widget's own filter), recording both numbers", + "first make the live-session predicate discriminate — a fresh boot holds no revoked or expired sys_session row, so the live count and the table count are equal and clause 1 proves nothing (QA run #23927 F3): sign the admin in a SECOND time (POST /api/v1/auth/sign-in/email; keep the returned token) and, from the first session, revoke the second one (POST /api/v1/auth/revoke-session {\"token\": the second token}). An interactive revoke TOMBSTONES the row in place — revoked_at and revoke_reason 'user_revoked' stamped, expires_at moved into the past (packages/plugins/plugin-auth/src/session-tombstone.ts#INTERACTIVE_REVOKE_REASON) — whereas sign-out deletes it, so use the revoke. Confirm the unfiltered sys_session count now exceeds the live-predicate count, then reload the board. THEN establish ground truth via the data API: a count aggregate on sys_user, and a count aggregate on sys_session restricted to LIVE sessions (revoked_at is null AND expires_at after now — the Active Sessions widget's own filter), recording both numbers and the unfiltered sys_session count", "read the rendered value of widget_total_users (Total Users) and widget_active_sessions (Active Sessions) from the DOM (only after the screenshot confirmed render) and compare each to its API count", "confirm widget_organizations and widget_packages_installed are ABSENT from the rendered board (runtime-gated in single-tenant), not showing a broken/empty tile; read the served dashboard too — widget_organizations is already missing from GET /api/v1/meta/dashboard/system_overview (the server's requiresService strip), widget_packages_installed is present there and hidden by the console", "walk every remaining widget: the two security KPI tiles (Login Events and Config Changes, event counts over sys_audit_log_metrics — the board carries no permission-events tile; system_overview.dashboard.ts records why the 'Permission Changes' tile was removed), the Audit Events by Action pie, the Events by User bar, and the Event Volume by Action table — confirm each draws a real mark or a named error state, none blank", @@ -918,7 +930,7 @@ { "clause": "widget_active_sessions shows the live session count — its value equals a direct count aggregate on sys_session under the widget's OWN predicate: the tile declares filter {revoked_at: null, expires_at: {$gt: '{now}'}} (#7531; '{now}' resolved per request by resolveFilterTokens) and opts out of the date bar, so 'Active' is honest and the oracle is the count of live sessions, not the full table", "oracle": "api", - "verify": "the rendered Active Sessions tile == COUNT(sys_session WHERE revoked_at is null AND expires_at > now) from a direct /api/v1/data aggregate taken in the same run; a value equal to the unfiltered table count while revoked or expired rows exist is a FAIL", + "verify": "the rendered Active Sessions tile == COUNT(sys_session WHERE revoked_at is null AND expires_at > now) from a direct /api/v1/data aggregate taken in the same run; a value equal to the unfiltered table count while revoked or expired rows exist is a FAIL. The run must hold at least one tombstoned row (steps[2]), so that the live and unfiltered counts differ; while they are equal the clause has not discriminated and is not a pass", "evidence": "the tile value + the predicate-restricted API count (and the unfiltered count beside it, when they differ)" }, { @@ -975,6 +987,12 @@ "date": "2026-10-13", "change": "acceptance[1] (clause, verify, evidence), acceptance[3].verify, knownGaps[0], steps[2], [4] and [5] and source[0] (stale spec, #23166 A1 / A3; parked on #23121, carried by #23169). The Active Sessions widget filters revoked_at null and expires_at > now (#7531), so the caveat 'no active-only filter' is gone and the oracle is the live-session count. The board has two security KPI tiles, Login Events and Config Changes: the Permission Changes tile was removed. system_overview declares 9 widgets, and the server strips widget_organizations from the served body on an unmet requiresService (filterDashboardForUser, ADR-0057 D10), so a single-tenant boot serves 8. QA run #23166 read 8 served widgets and no permission KPI at objectstack 36b2889c; the source reads the same at 6d7f4df3c", "ref": "#23169" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "steps[2] and acceptance[1].verify gain the discriminating fixture (fixture gap F3 of QA run #23927, wave #23878). On a fresh boot no sys_session row is revoked or expired, so the live-session predicate and the table count were both 34 and clause 1 could not show the tile applies the predicate. The step now tombstones one session first — a second admin sign-in revoked through POST /api/v1/auth/revoke-session, which stamps revoked_at in place (session-tombstone.ts) where sign-out would delete the row — and confirms the two counts differ before reading the tile; the verify text records that equal counts are not a pass", + "ref": "#24036" } ] }, @@ -983,7 +1001,7 @@ "title": "The showcase_delivery analytics cube serves /api/v1/analytics/*: meta discovers its measures/dimensions, a query answers a known aggregate that reconciles against a direct /data aggregate, and an unwired analytics slot degrades honestly to 404", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "api", "personas": [ @@ -1009,7 +1027,7 @@ { "clause": "meta discovers the cube: GET /analytics/meta lists showcase_delivery with exactly its measures (namespaced showcase_delivery.count / .total_estimate_hours / .avg_estimate_hours) and four dimensions (showcase_delivery.status / .priority / .due_date / .assignee)", "oracle": "api", - "verify": "the meta response's cubes[] entry for showcase_delivery names each measure and dimension the clause lists (getMeta keys them `${cube}.${key}`, and lists a cube only when its `public` is not `false`: a cube declaring `public: false` is omitted from meta and refused by query()/generateSql() with 404 CUBE_NOT_FOUND. showcase_delivery declares no `public`, so it is visible by the schema default `true`)", + "verify": "the meta response's showcase_delivery entry names each measure and dimension the clause lists. Read it from `data`, which is a BARE array of CubeMeta projections, one per cube (`?cube=showcase_delivery` answers a one-element array): there is no `cubes` wrapper object, so `data.cubes` reads nothing on a correct body (packages/spec/src/api/analytics.zod.ts#AnalyticsMetadataResponseSchema, #6442) (getMeta keys them `${cube}.${key}`, and lists a cube only when its `public` is not `false`: a cube declaring `public: false` is omitted from meta and refused by query()/generateSql() with 404 CUBE_NOT_FOUND. showcase_delivery declares no `public`, so it is visible by the schema default `true`)", "evidence": "the /analytics/meta?cube=showcase_delivery body" }, { @@ -1063,7 +1081,8 @@ "examples/app-showcase/src/data/analytics/showcase.cube.ts#showcase_project (the showcase_delivery cube — measures, dimensions, base table, showcase_project join; no `public` key, so visible by the default `true`)", "examples/app-showcase/src/coverage.ts#analyticsCubes (analyticsCubes registration → src/data/analytics/showcase.cube.ts, served by /api/v1/analytics/*)", "packages/runtime/src/domains/analytics.ts#cube (route contract: POST /analytics/query, GET /analytics/meta[?cube], entry validation, ExecutionContext scoping #2852, handled:false 404 for an absent/stub slot #3891/#4000)", - "packages/spec/src/api/analytics.zod.ts#AnalyticsQueryRequestSchema (AnalyticsQueryRequestSchema bare shape + retiredKey query/format; meta response cubes[])", + "packages/spec/src/api/analytics.zod.ts#AnalyticsQueryRequestSchema (AnalyticsQueryRequestSchema bare shape + retiredKey query/format)", + "packages/spec/src/api/analytics.zod.ts#AnalyticsMetadataResponseSchema (the GET /analytics/meta body: data is a bare array of CubeMeta projections — name, title, description, measures, dimensions — with no cubes wrapper and no cube sql, #6442)", "packages/services/service-analytics/src/analytics-service.ts#getMeta (getMeta keys measures/dimensions as `${cube}.${key}` and omits a registry cube whose `public` is `false` (cube-visibility.ts#isCubePublic); query()/generateSql() refuse such a cube with 404 CUBE_NOT_FOUND through assertCubePublic)", "packages/cli/src/commands/serve.ts#CAPABILITY_PROVIDERS (CAPABILITY_PROVIDERS.analytics → @objectstack/service-analytics, configKey analyticsCubes)" ], @@ -1085,6 +1104,12 @@ "date": "2026-10-01", "change": "dropped done_rate from the fixture line and the meta clause, re-spelled the verify text that counted it, and removed its variant: a cube member's `sql` is now a column reference (MetricSchema in packages/spec/src/data/analytics.zod.ts refuses a SQL expression at parse), so the showcase_delivery cube no longer declares the CASE-expression done_rate measure, and the done rate is declared on the showcase_task_metrics dataset (examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts) as a filtered count over a count. The steps, the reconciliation clauses and the four dimensions are unchanged", "ref": "#20943" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "acceptance[0].verify and source[3] re-pointed to the meta wire shape (checklist-accuracy finding C1 of QA run #23927, wave #23878). GET /api/v1/analytics/meta answers `data` as a BARE array of CubeMeta projections — AnalyticsMetadataResponseSchema says outright there is no `cubes` wrapper (#6442) — so the verify text's 'cubes[] entry' named a key the body never carries. The clause still requires every listed measure and dimension on the showcase_delivery entry; source gains the response schema's anchor", + "ref": "#24036" } ] }, diff --git a/docs/qa/platform-checklist/areas/i18n.json b/docs/qa/platform-checklist/areas/i18n.json index 78d6ae0cd1..54db868075 100644 --- a/docs/qa/platform-checklist/areas/i18n.json +++ b/docs/qa/platform-checklist/areas/i18n.json @@ -9,7 +9,7 @@ "title": "Notification mark-as-read is server-authoritative, the deep-link target is localized — and the title is the flow-authored literal string, because notification copy has no translation group", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "browser", "personas": ["zh-CN session user receiving an app-flow notification", "en session recipient (the control that proves the title is recipient-INDEPENDENT)"], @@ -20,14 +20,14 @@ "a second user to assign work to, so the assignment notification has a recipient distinct from the actor" ], "knownGaps": [ - "per-recipient localized notification COPY is not a capability of the app-flow notify path, and this item pins that contract instead of assuming the retired one: `notifications` is a RETIRED top-level translation key whose own spec guidance reads \"`notifications` is the retired object-first dialect — notifications have no translation group, omit them\" (packages/spec/src/system/translation.zod.ts, verified on origin/main); `sys_user` declares no `locale` field; and the notify executor interpolates cfg.title against flow variables with no locale lookup anywhere (packages/services/service-automation/src/builtin/notify-node.ts — `const title = stringifyForTemplate(interpolate(cfg.title ?? '', variables, context))`). Platform-side assignment notifications moved to app-authored flows in #3403, pinned by packages/plugins/plugin-audit/src/audit-writers.test.ts ('does NOT emit an assignment notification when an owner field is set'). ⛔ Do not re-assert a localized-title premise on this item", + "the INLINE notification title this item drives is not localized, and the item pins that contract instead of assuming the retired one: `notifications` is a RETIRED top-level translation key whose own spec guidance reads \"`notifications` is the retired object-first dialect — notifications have no translation group, omit them\" (packages/spec/src/system/translation.zod.ts), and the notify node renders its inline `title` slot per run from {{ }} placeholders with no locale lookup — its own config schema calls the slot \"Not localizable — use template for per-locale content\" (packages/services/service-automation/src/builtin/notify-node.ts). Per-recipient localized copy DOES exist on the app-flow path, through the OTHER slot: a notify node that names a `template` (with templateData) instead of an inline title is rendered per recipient at delivery, by the inbox channel and the email channel alike, in the recipient's own sys_user.locale, else the deployment default (#13881; packages/services/service-messaging/src/inbox-channel.ts). The showcase's notify_assignee authors an inline title, so its bell entry is the same literal string in every locale. sys_user.locale exists, and the avatar-menu language switcher writes it. Platform-side assignment notifications moved to app-authored flows in #3403, pinned by packages/plugins/plugin-audit/src/audit-writers.test.ts ('does NOT emit an assignment notification when an owner field is set'). ⛔ Do not re-assert a localized-title premise for an INLINE title", "the one platform notification path that IS per-recipient localized is a DIFFERENT producer, and knowing it is what stops the over-correction: plugin-audit's collab.mention writer resolves messages.mentionedYou per recipient (resolveWriteLocale(tenantId, uid) → the ADR-0053 localization context) through the long-declared `messages` translation group (packages/plugins/plugin-audit/src/audit-writers.ts + src/translations/messages.ts). That is a plugin-emitted title through an existing group — not a `notifications` group, and not the app-flow path this item drives. 'Notification titles are never localized' is as wrong as 'the bell title should be localized'" ] }, "steps": [ "boot the showcase under os dev/standalone and sign in as admin", - "put the RECIPIENT's own browser session in Chinese — there is no per-user locale field to set (sys_user declares none). The console LocaleSwitcher persists localStorage['objectui-locale'] and accepts LANGUAGE-only codes: write `zh`, which the server resolves to zh-CN for /i18n/* and Accept-Language-driven metadata localization; writing `zh-CN` there is rejected by isKnownLanguage() and silently boots en (#7640)", - "read the showcase flow's notify-node config first (examples/app-showcase/src/automation/flows/index.ts — the record-after-update flow on `assignee != previous.assignee`, notify node `notify_assignee`, topic `task.assigned`, channel inbox) and write down cfg.title verbatim; the title expectation is that literal template with {record.*} interpolated, derived from the flow, not from a locale", + "put the RECIPIENT in Chinese the way a user does: signed in as the recipient, pick 中文 in the avatar-menu language switcher, which writes the recipient's own sys_user.locale (zh-CN) and the console's UI locale together (objectui packages/app-shell/src/layout/LocaleSwitcher.tsx). The server-side equivalent is the recipient's own PATCH /api/v1/data/sys_user/RECIPIENT_ID {\"locale\":\"zh-CN\"} followed by a reload. Seeding browser storage alone is not a language switch — it leaves sys_user.locale empty (i18n.studio-follows-app-locale step 0 records the harness gap that cost a run)", + "read the showcase flow's notify-node config first (examples/app-showcase/src/automation/flows/index.ts — the record-after-update flow on `assignee != previous.assignee`, notify node `notify_assignee`, topic `task.assigned`, channel inbox) and write down cfg.title verbatim (stock: 'New task assigned: {{ record.title }}'); confirm the node authors an inline `title` and no `template`. The title expectation is that literal template with its {{ record.* }} placeholders rendered, derived from the flow, not from a locale", "trigger it by changing a seeded showcase_task's assignee to the recipient. ⛔ Not `collab.assignment` — the kernel stopped emitting assignment notifications in #3403; that topic survives only in regression pins", "sign in as the recipient (own browser context — never a shared tab), open the bell, and screenshot the notification entry", "click through the notification's deep link and screenshot the target detail page", @@ -37,9 +37,9 @@ ], "acceptance": [ { - "clause": "the bell entry's title is the flow-authored literal string with its {record.*} placeholders interpolated — notification copy is NOT localized, and this clause pins the shipped contract rather than the retired one", + "clause": "the bell entry's title is the flow-authored literal string with its {{ record.* }} placeholders rendered — an INLINE notification title is NOT localized, and this clause pins the shipped contract rather than the retired one", "oracle": "screenshot", - "verify": "the bell entry renders exactly cfg.title from the showcase flow's notify node with {record.title} substituted (stock showcase: 'New task assigned: '), character for character, on a zh-CN session. The executor interpolates the template with no locale lookup, so an English title on a Chinese session is the PASS here. A raw unsubstituted '{record.title}' IS a FAIL — interpolation, not translation, is what this clause tests", + "verify": "the bell entry renders exactly cfg.title from the showcase flow's notify node with its {{ record.title }} placeholder rendered (stock showcase: 'New task assigned: ' followed by the task's title), character for character, on a zh-CN session. The node renders an inline title with no locale lookup, so an English title on a Chinese session is the PASS here. A raw, unrendered '{{ record.title }}' token IS a FAIL — rendering, not translation, is what this clause tests", "evidence": "bell screenshot next to the flow's notify-node config" }, { @@ -61,9 +61,9 @@ "evidence": "post-reload unread-count read" }, { - "clause": "the title is recipient-INDEPENDENT: the same event notifies an EN-session and a zh-CN-session recipient with the identical title string — per-recipient localization of app-flow notification copy is a spec-RETIRED capability, not a gap this run should report", + "clause": "the title is recipient-INDEPENDENT: the same event notifies an EN-session and a zh-CN-session recipient with the identical title string — the showcase flow authors an INLINE title, which the notify node renders with no locale lookup. Per-recipient copy exists only through a notify `template` reference (rendered in each recipient's sys_user.locale at delivery), which this flow does not use", "oracle": "screenshot", - "verify": "the control recipient's bell entry carries the same title string as the zh-CN recipient's, modulo the interpolated record values. A DIFFERENCE is the finding in this clause — it would mean an undeclared per-recipient localization path appeared under a contract that retired one: re-read translation.zod.ts's `notifications` guidance, confirm which producer emitted the row (an app flow's notify node, or a plugin writer resolving an existing group such as messages.mentionedYou), and file rather than tick", + "verify": "the control recipient's bell entry carries the same title string as the zh-CN recipient's, modulo the rendered record values. A DIFFERENCE is the finding in this clause — it would mean the inline title slot was localized per recipient, which its contract says it never is: confirm which producer emitted the row (the showcase flow's notify node, or a plugin writer resolving an existing group such as messages.mentionedYou) and file rather than tick. If the flow now names a `template` instead of an inline title, its per-recipient members are DECLARED and this item's premise has moved — revise the item, do not tick", "evidence": "the two bell screenshots side by side" } ], @@ -81,13 +81,15 @@ "packages/spec/src/system/translation.zod.ts#TRANSLATION_KEY_GUIDANCE (TRANSLATION_KEY_GUIDANCE — `notifications` is the retired object-first dialect; notifications have no translation group)", "#3403 (platform-side assignment notifications moved to app-authored flows; pinned by packages/plugins/plugin-audit/src/audit-writers.test.ts)", "examples/app-showcase/src/automation/flows/index.ts#notify_assignee (the notify_assignee node whose literal cfg.title this item now asserts)", - "packages/services/service-automation/src/builtin/notify-node.ts#title (title = interpolate(cfg.title) — no locale lookup on the flow notify path)", + "packages/services/service-automation/src/builtin/notify-node.ts#title (the inline title is rendered per run from {{ }} placeholders with no locale lookup — the node schema calls it 'Not localizable'; the localizable path is the `template` slot)", + "packages/services/service-messaging/src/inbox-channel.ts (a notify `template` reference is rendered per recipient at delivery, in the recipient's sys_user.locale else the deployment default, #13881 — the per-recipient path this item's inline-title flow does not take)", "#7684 (the decision this revision implements), #7640 (the run that surfaced it)" ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "initial import from #3358; the #3362 route-seam regression is the reason the oracle is a live-server network trace, never a unit test", "ref": "#3358" }, { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 3, "date": "2026-08-11", "change": "c0/c4 REWRITTEN against the shipped contract (#7684). Both clauses assumed a per-recipient localized notification title — a capability the spec retired — so on a stock app they were unprovable and landed blocked(fixture) rather than testing anything. c0 now asserts the flow-authored literal title with its placeholders interpolated; c4 now asserts the title is recipient-INDEPENDENT, with a per-recipient difference as the finding. The mechanical read/clear clauses (live-server POST /notifications/read + the survives-reload re-read) are untouched and remain the item's substance. The retirement is cited on the item (translation.zod.ts, #3403, notify-node.ts) so the next author cannot re-assert the retired premise; the steps drop the unperformable 'set the recipient user's locale' (sys_user has no locale field) and the stale collab.assignment trigger. Verified before editing: the retirement guidance is still on origin/main and no notification-copy translation group has appeared — plugin-audit's per-recipient collab.mention title resolves the pre-existing `messages` group, a different producer, and is recorded in knownGaps so the correction does not overshoot", "ref": "#7684" } + { "revision": 3, "date": "2026-08-11", "change": "c0/c4 REWRITTEN against the shipped contract (#7684). Both clauses assumed a per-recipient localized notification title — a capability the spec retired — so on a stock app they were unprovable and landed blocked(fixture) rather than testing anything. c0 now asserts the flow-authored literal title with its placeholders interpolated; c4 now asserts the title is recipient-INDEPENDENT, with a per-recipient difference as the finding. The mechanical read/clear clauses (live-server POST /notifications/read + the survives-reload re-read) are untouched and remain the item's substance. The retirement is cited on the item (translation.zod.ts, #3403, notify-node.ts) so the next author cannot re-assert the retired premise; the steps drop the unperformable 'set the recipient user's locale' (sys_user has no locale field) and the stale collab.assignment trigger. Verified before editing: the retirement guidance is still on origin/main and no notification-copy translation group has appeared — plugin-audit's per-recipient collab.mention title resolves the pre-existing `messages` group, a different producer, and is recorded in knownGaps so the correction does not overshoot", "ref": "#7684" }, + { "revision": 4, "date": "2026-10-16", "change": "knownGaps[0], steps[1] and [2], acceptance[0] (clause, verify) and acceptance[4] (clause, verify) and source[7] re-pointed (checklist-accuracy note 1 of QA run #23951, wave #23878). Three premises had moved: sys_user.locale exists and the avatar-menu switcher writes it (zh-CN accepted), so steps[1]'s storage-only, language-code-only recipe is replaced by the switcher (or the recipient's own PATCH of sys_user.locale); the notify node gained a per-recipient `template` path (#13881), so 'per-recipient localization is spec-RETIRED' is wrong — only the INLINE title is unlocalized, which is what the showcase flow uses; and placeholders are {{ record.title }}, so the FAIL shape is an unrendered double-brace token. The clauses are not loosened: clause 0 still demands the literal rendered title, and clause 4 still files any per-recipient difference on an inline title; a flow that moved to a `template` is a premise change that revises the item, never a tick", "ref": "#24036" } ] }, { @@ -95,32 +97,32 @@ "title": "Studio follows the in-app locale switch — no mixed-language session", "since": "v16", "status": "active", - "revision": 5, + "revision": 6, "priority": "P2", "surface": "browser", "personas": ["admin"], "fixtures": { "app": "showcase", "requires": [ - "the platform-owned zh-CN metadata-form translations (the metadataForms translation group is owned and translated by platform-objects — scripts/check-i18n-coverage.mjs folds that baseline away because it is platform-owned)" + "the console's own zh catalogue for the Studio designers: the object editor's section and field labels are objectui's metadata-admin `designer.object.*` strings (objectui packages/app-shell/src/views/metadata-admin/i18n.ts, read by inspectors/ObjectDefaultInspector.tsx), NOT the served metadataForms translation group — QA run #23951 read 标识、显示名与分类 / 显示名 where the served group carries …分类标签 / 显示名称. The metadataForms group stays platform-owned (platform-objects translates it, and scripts/check-i18n-coverage.mjs folds that baseline away)" ], "knownGaps": [ - "the showcase's declared surface carries FROZEN untranslated debt, ratcheted by scripts/check-i18n-coverage.mjs + scripts/i18n-coverage-baseline.json (examples/app-showcase/objectstack.config.ts: 451 untranslated declared labels, held flat — growth fails the gate). Residual English strings on an otherwise-Chinese Studio surface (#7640 recorded `Announcement`, `Business Unit`, `Owning Business Unit`, `Sales Region`) render their EN SOURCE label, never a raw key, and are legitimate — exactly as i18n.surface-matrix's knownGaps already declares for the same fixture. This item asserts consistency WITH the bundle, not 100% coverage" + "the showcase's declared surface carries FROZEN untranslated debt, ratcheted by scripts/check-i18n-coverage.mjs + scripts/i18n-coverage-baseline.json (examples/app-showcase/objectstack.config.ts: 414 untranslated declared labels in scripts/i18n-coverage-baseline.json, held flat — growth fails the gate, and the count only shrinks). Residual English strings on an otherwise-Chinese Studio surface (#7640 recorded `Announcement`, `Business Unit`, `Owning Business Unit`, `Sales Region`) render their EN SOURCE label, never a raw key, and are legitimate — exactly as i18n.surface-matrix's knownGaps already declares for the same fixture. This item asserts consistency WITH the bundle, not 100% coverage" ] }, "steps": [ "sign in as admin on the showcase, switch the in-app locale to zh-CN, and confirm the app shell followed (nav in zh-CN). To switch, use the avatar-menu language switcher (it writes the user's locale); seeding browser storage alone is not a language switch — a run that only seeds the UI language in browser storage leaves the user's stored locale empty, so dates follow the browser and a FAIL read off it is the harness's, not the product's", "navigate into Studio / metadata-admin: open the object editor for showcase_project and the dashboard designer for showcase_chart_gallery", - "screenshot the object editor's form — its labels/sections come from the metadataForms translation group (packages/spec/src/system/translation.zod.ts: metadataForms..label / sections / fields)", + "screenshot the object editor's form — its section and field labels come from objectui's metadata-admin catalogue (`designer.object.*` in packages/app-shell/src/views/metadata-admin/i18n.ts at the .objectui-sha pin), not from the served metadataForms translation group (packages/spec/src/system/translation.zod.ts): judge them against that catalogue (e.g. section 基础信息 with hint 标识、显示名与分类)", "screenshot a surface showing RELATIVE dates in the switched locale: the Studio Data pillar's records grid (/_console/studio//data, an object's records), whose timestamps render as '6天前'-style relative forms. The metadata list and history surfaces show none — history renders absolute timestamps — so they cannot score this step", "reload the browser fully and re-screenshot one Studio surface — the locale choice must survive the reload", "switch back to en through the same avatar-menu language switcher and re-screenshot the same two Studio surfaces" ], "acceptance": [ { - "clause": "Studio's metadata-editor forms render in the switched locale via the metadataForms translation group", + "clause": "Studio's metadata-editor forms render in the switched locale — for the object editor through objectui's own metadata-admin catalogue (`designer.object.*`), which is where its strings come from; the served metadataForms group is not what this editor renders", "oracle": "screenshot", - "verify": "object-editor screenshot shows zh-CN section and field labels (e.g. 基础信息-style section labels per the schema's own example), not English", + "verify": "object-editor screenshot shows zh-CN section and field labels that match objectui's metadata-admin catalogue at the pin (e.g. section 基础信息, hint 标识、显示名与分类), not English", "evidence": "object-editor screenshot" }, { @@ -149,12 +151,13 @@ } ], "negative": [ - "a Studio surface staying English after the app switched (mixed session) is the FAIL this item exists for — judged against the bundle, not by counting English words: a string the platform metadataForms bundle translates rendering English is the FAIL; a string inside the frozen coverage debt rendering its EN source label is not. Check against a fresh objectui build before filing either way (the vendored /_console bundle may be stale)" + "a Studio surface staying English after the app switched (mixed session) is the FAIL this item exists for — judged against the bundle, not by counting English words: a string the bundle feeding that surface translates (the console's metadata-admin catalogue for the Studio designers, the app's bundle for app labels such as navigation) rendering English is the FAIL; a string inside the frozen coverage debt rendering its EN source label is not. Check against a fresh objectui build before filing either way (the vendored /_console bundle may be stale)" ], "traps": ["stale-console-bundle"], "source": [ "#3358 §7", - "packages/spec/src/system/translation.zod.ts#metadataForms (metadataForms group + resolveMetadataFormLabels convention)", + "packages/spec/src/system/translation.zod.ts#metadataForms (metadataForms group + resolveMetadataFormLabels convention — the served group, which the Studio object editor does not render)", + "objectui packages/app-shell/src/views/metadata-admin/i18n.ts (the `designer.object.*` zh strings) + inspectors/ObjectDefaultInspector.tsx (the object editor that reads them) — where the object editor's labels come from (QA run #23951, checklist-accuracy note 3)", "scripts/check-i18n-coverage.mjs#baseline (platform metadata-form baseline is platform-objects-owned)", "scripts/i18n-coverage-baseline.json (the frozen per-config untranslated counts this item's mixed-language clause is judged against)", "#7640 (i18n area run — the residual EN strings that exposed the clause/ratchet collision), #7686", @@ -165,7 +168,8 @@ { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 3, "date": "2026-08-11", "change": "the mixed-language clause is reconciled with the coverage ratchet (#7640): as written it read 'no untranslated declared string next to translated ones', which the showcase's FROZEN untranslated debt violates by design — the sibling item i18n.surface-matrix already declares that debt legitimate, so this item contradicted it and cost a clean PASS. The clause now judges against the bundle (translated key renders translated; untranslated key renders its EN SOURCE label; a raw dotted key is the unconditional FAIL), the same knownGap is declared on this item's fixtures, and the negative carries the matching qualifier", "ref": "#7686" }, { "revision": 4, "date": "2026-10-05", "change": "the switch steps now name HOW to switch: through the avatar-menu language switcher, which writes the user's locale. Seeding browser storage alone is not a language switch. The 17.7 console run held a relative-date FAIL on this item, and its re-run on #21782 settled it as a harness gap: the first lane had set the UI language only in browser storage, so the user's stored locale stayed empty and dates followed the browser. Through the switcher the clause passed, so the clauses themselves are unchanged (card #21851 entry 11, filed with the follow-up run #21845)", "ref": "#21782" }, - { "revision": 5, "date": "2026-10-13", "change": "steps[3] and acceptance[2].verify re-pointed to the surface that renders relative dates (stale step, #23163 A2; parked on #23121, carried by #23169). The metadata list and history surfaces carry no relative dates — history is absolute — so the step pointed at nothing scorable. QA run #23163 read relative dates (6天前) on the Studio Data pillar's records grid under zh-CN, through the avatar-menu switcher, and passed", "ref": "#23169" } + { "revision": 5, "date": "2026-10-13", "change": "steps[3] and acceptance[2].verify re-pointed to the surface that renders relative dates (stale step, #23163 A2; parked on #23121, carried by #23169). The metadata list and history surfaces carry no relative dates — history is absolute — so the step pointed at nothing scorable. QA run #23163 read relative dates (6天前) on the Studio Data pillar's records grid under zh-CN, through the avatar-menu switcher, and passed", "ref": "#23169" }, + { "revision": 6, "date": "2026-10-16", "change": "knownGaps[0], fixtures.requires[0], steps[2], acceptance[0] (clause, verify), negative[0] and source re-pointed (checklist-accuracy notes 2 and 3 of QA run #23951, wave #23878). The clause-0 premise was wrong: the Studio object editor's strings come from objectui's metadata-admin catalogue (designer.object.* in packages/app-shell/src/views/metadata-admin/i18n.ts, ObjectDefaultInspector.tsx), not the served metadataForms group — #23951 read 标识、显示名与分类 / 显示名 where the group carries …分类标签 / 显示名称 — so the clause passed on its verify text while naming the wrong source, and negative[0] judged residual English against the wrong bundle. Both now name the catalogue that feeds the surface. The frozen debt count is 414, not 451 (scripts/i18n-coverage-baseline.json). Not loosened: a zh session still FAILS on any English string the feeding bundle translates, and on any raw dotted key", "ref": "#24036" } ] }, { @@ -173,7 +177,7 @@ "title": "Every translatable surface localizes on a zh-CN session — one pass over the full translation-group vocabulary, plus the runtime i18n routes it resolves through", "since": "v15", "status": "active", - "revision": 7, + "revision": 8, "priority": "P1", "surface": "browser", "personas": ["admin on a zh-CN session"], @@ -199,8 +203,8 @@ "an action dialog — trigger an object action with confirmText/successMessage and capture the confirm prompt and the success toast (_actions group: label/confirmText/successMessage)", "a view empty state (_views..emptyState) — RUNNABLE on the stock fixture since the #7714 specimen landed: examples/app-showcase/src/ui/views/task.view.ts authors an emptyState on the filtered `urgent` view, and the zh-CN bundle carries objects.showcase_task._views.urgent.emptyState.{title,message}. Demote the two urgent seed rows (or filter them away) so the view renders empty, then read the copy — it resolves to 暂无紧急任务 / 当前没有需要立即处理的事项。任务优先级调整为「紧急」后会出现在这里。 ⛔ Still do not manufacture coverage from the console's built-in zero-row placeholder, which is console chrome and not an objects._views..emptyState resolution", "section labels (_sections group) — read them off a NAMED-section surface the console actually renders: showcase_task's New form (its default form's `task` section), a task record page (task-detail.page.ts names overview / schedule / details since #22036) and showcase_contact's default edit form (ui/views/contact.view.ts). Each declares a stable `name` per section, which is the only thing that makes objects.._sections..label resolvable — the showcase bundle carries 概览/排期/详细信息 and 联系方式/工作信息/状态/备注 for exactly those names. ⛔ Not `…/view/tabbed`: the named formViews are reachable from no console door, and that URL renders the grid (see the knownGap below)", - "a dashboard — Chart Gallery's title and widget titles (dashboards..label / widgets..title)", - "a page — a seeded page's header title/subtitle (pages. group)", + "a dashboard — Chart Gallery's title and widget titles (dashboards..label / widgets..title). The showcase bundle translates one widget title: dashboards.showcase_chart_gallery.widgets.combo_count_vs_progress.title → 任务数与平均进度 on the runtime dashboard; read that widget", + "a page — a page header's title/subtitle (pages. group). No showcase page carries a translated title or subtitle (QA run #23951 fixture gap), so read a PLATFORM page: pages.connect_agent (label 连接智能体 and its zh subtitle, platform-objects' bundle), and the showcase_command_center page's translated component titles (pages.showcase_command_center.components.*)", "a Settings namespace — title, group titles, key labels, and the source badges (settings. + settingsCommon.sourceLabels)", "Studio metadata forms (metadataForms group — overlaps i18n.studio-follows-app-locale; a pinned pass there may be cited)", "a sys_ object surface — e.g. the All Users list showing sys_user's translated label (platform-objects-owned bundle)", @@ -255,7 +259,7 @@ "evidence": "the /i18n/locales response" }, { - "clause": "GET /i18n/translations/:locale returns a non-empty bundle whose key paths all sit within the declared translation-group vocabulary (PlatformTranslationDataSchema — the SERVED document is the merged tree, so it is the eleven-group platform face and not the ten-group per-app one) — a key outside the declared groups, or a raw dotted key handed to a consumer, is the silent-strip class", + "clause": "GET /i18n/translations/:locale returns a non-empty bundle whose key paths all sit within the declared translation-group vocabulary (PlatformTranslationDataSchema — the SERVED document is the merged tree, so it is the twelve-group platform face — the eleven per-app groups plus the platform-only `settings` — and not the eleven-group per-app one) — a key outside the declared groups, or a raw dotted key handed to a consumer, is the silent-strip class", "oracle": "api", "verify": "translations for zh-CN is non-empty; spot-check that sampled keys resolve under objects/_views/_actions/_sections/apps.navigation/dashboards/pages/settings groups, not a fourth dialect", "evidence": "the /translations/zh-CN response + the key check" @@ -291,7 +295,7 @@ "select option labels (objects.fields.options)", "form labels/placeholders (objects.fields)", "action dialogs (objects._actions: label/confirmText/successMessage)", - "view empty states (objects._views.emptyState) — blocked(fixture): no showcase view authors one", + "view empty states (objects._views.emptyState) — runnable on stock: the `urgent` view authors one (#7714) and the showcase bundle carries its zh-CN half; empty the view first (steps[6])", "section labels (objects._sections) — on a named-section surface the console renders: the task New form, the task record page or the contact form; the named formViews (tabbed / wizard / split) are reachable from no console door", "dashboards (dashboards.label / widgets.title)", "pages (pages.label/title/subtitle)", @@ -304,10 +308,10 @@ "traps": ["stale-console-bundle", "hydration-race", "wrong-panel", "dispatcher-vs-hono-route"], "automated": { "kind": "unit", - "ref": "objectui, for acceptance[5]'s CONFIRM half ONLY: packages/components/src/__tests__/page-header-action-i18n.test.tsx ('renders the translated button AND opens the confirm dialog with the translated body') + packages/plugin-detail/src/renderers/__tests__/record-quick-actions.actionText-i18n.test.tsx ('resolves the button AND the confirm dialog body from the same bundle entry'). Each runs the real ActionRunner under a real I18nProvider holding a zh-CN `_actions.NAME.confirmText` entry. Run them in an objectui checkout at the .objectui-sha revision (present at 0abd4f9f8769) and cite the output; every other clause still needs the run" + "ref": "objectui, for acceptance[5]'s CONFIRM half ONLY: packages/components/src/__tests__/page-header-action-i18n.test.tsx ('renders the translated button AND opens the confirm dialog with the translated body') + packages/plugin-detail/src/renderers/__tests__/record-quick-actions.actionText-i18n.test.tsx ('resolves the button AND the confirm dialog body from the same bundle entry'). Each runs the real ActionRunner under a real I18nProvider holding a zh-CN `_actions.NAME.confirmText` entry. Run them in an objectui checkout at the .objectui-sha revision (both files present at f8267df93aba; QA run #23951 ran them at daeb77fc84dd, 2 files, 7/7) and cite the output; every other clause still needs the run" }, "source": [ - "packages/spec/src/system/translation.zod.ts#appTranslationDataShape (appTranslationDataShape — the group vocabulary an APPLICATION may author: objects/_views/_actions/_sections, apps.navigation, messages, globalActions, dashboards, datasets, pages, flows, metadataForms, settingsCommon. `settings` is platform-only since #15178 and lives in platformSettingsShape, spread into PlatformTranslationDataSchema only — TranslationItemSchema refuses it too since #19620)", + "packages/spec/src/system/translation.zod.ts#appTranslationDataShape (appTranslationDataShape — the eleven-group vocabulary an APPLICATION may author: objects (with _views/_actions/_sections), picklists, apps (navigation), messages, globalActions, dashboards, datasets, pages, flows, metadataForms, settingsCommon. `settings` is platform-only since #15178 and lives in platformSettingsShape, spread into PlatformTranslationDataSchema only — TranslationItemSchema refuses it too since #19620)", "examples/app-showcase/src/system/translations/index.ts (full-column coverage rationale)", "packages/services/service-i18n/src/i18n-service-plugin.ts#i18n (GET /i18n/locales | /translations/:locale | /labels/:object/:locale; { success, data } envelope #3636/#3675; resolveObjectFieldLabels nested shape #3778/#3833; the plugin mount and the dispatcher /i18n domain serve the same routes interchangeably)", "packages/services/service-i18n/src/file-i18n-adapter.ts#getLocales (getLocales / getTranslations — unloaded locale → {}; fallbackLocale applies per-KEY in t(), not to the bulk route)", @@ -326,7 +330,8 @@ { "revision": 4, "date": "2026-08-18", "change": "two corrections. (1) The #7714 emptyState waiver is stale: the specimen has landed — task.view.ts authors an emptyState on the urgent view and the zh-CN bundle carries objects.showcase_task._views.urgent.emptyState.{title,message} — so step 7 and the knownGap now describe a runnable variant instead of instructing blocked(fixture) (#9467 CF-1). (2) Clause 11 asserted the feed/audit verb localizes on the READER's session; audit-writers.ts renders the summary once at write time via resolveWriteLocale(tenantId, userId) and stores it, so only the tenant setting at write time moves it. The clause and its step now flip the tenant locale before writing — this premise nearly produced a false resolver FAIL (#9467 CF-2)", "ref": "#9386" }, { "revision": 5, "date": "2026-10-05", "change": "the switch step now names HOW to switch: through the avatar-menu language switcher, which writes the user's locale. Seeding browser storage alone is not a language switch. The 17.7 console run held the relative-dates variant of acceptance[0] as a FAIL, and its re-run on #21782 settled it as a harness gap: the first lane had set the UI language only in browser storage, so the user's stored locale stayed empty and dates followed the browser. Through the switcher the variant passed, so the clauses themselves are unchanged (card #21851 entry 11, filed with the follow-up run #21845)", "ref": "#21782" }, { "revision": 6, "date": "2026-10-06", "change": "partial-coverage gap of QA run #21782 parked on #22017 (row 8). A6's confirm half was blocked(fixture): no zh confirmText. Measured: no showcase action declares a confirmText at all, so there is no live specimen. Disposition: pin. The objectui tests page-header-action-i18n and record-quick-actions.actionText-i18n prove that the confirm dialog body resolves from the `_actions` zh-CN entry through the real ActionRunner. `automated` names them for that half only, and a knownGap records why the half is pin-only on stock", "ref": "#22017" }, - { "revision": 7, "date": "2026-10-13", "change": "knownGaps[2], steps[7], variants[6] and the showcase source note re-pointed (knownGap stale, #23163; parked on #23121, carried by #23169). task-detail.page.ts's record:details block has named its sections overview / schedule / details since #22036, so the task record page renders them translated; QA run #23163 read zh headings on the New form, the task record page and the contact form under zh-CN. The step used to name the tabbed / wizard / split formViews, but those are reachable from no console door, and `…/view/tabbed` renders the grid. The negative that keeps a nameless section out of resolver FAILs stands", "ref": "#23169" } + { "revision": 7, "date": "2026-10-13", "change": "knownGaps[2], steps[7], variants[6] and the showcase source note re-pointed (knownGap stale, #23163; parked on #23121, carried by #23169). task-detail.page.ts's record:details block has named its sections overview / schedule / details since #22036, so the task record page renders them translated; QA run #23163 read zh headings on the New form, the task record page and the contact form under zh-CN. The step used to name the tabbed / wizard / split formViews, but those are reachable from no console door, and `…/view/tabbed` renders the grid. The negative that keeps a nameless section out of resolver FAILs stands", "ref": "#23169" }, + { "revision": 8, "date": "2026-10-16", "change": "variants[5], acceptance[7].clause, steps[8] and [9], source[0] and automated.ref corrected (checklist-accuracy note 4 and the fixture gaps of QA run #23951, wave #23878). variants[5] still marked the view-empty-state variant blocked(fixture), contradicting knownGaps[1] and steps[6] — it ran and passed. acceptance[7] counted the served tree as an eleven-group platform face over a ten-group per-app one; the spec declares eleven per-app groups (appTranslationDataShape, picklists included — source[0]'s list had dropped it) and twelve platform groups (plus settings). The dashboard and page steps now name a translated specimen each, because no showcase page carries a translated title (the run scored pages on platform page connect_agent). automated.ref's revision note moves to the current pin. Wording and specimen names only: no request or oracle changed", "ref": "#24036" } ] }, { diff --git a/docs/qa/platform-checklist/areas/identity-auth.json b/docs/qa/platform-checklist/areas/identity-auth.json index 5b489b8a88..6dc2415831 100644 --- a/docs/qa/platform-checklist/areas/identity-auth.json +++ b/docs/qa/platform-checklist/areas/identity-auth.json @@ -9,7 +9,7 @@ "title": "ssoEnforced login honors SSO on first paint — no password-wall flash", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "browser", "personas": ["anonymous visitor at the login page", "env owner (break-glass password holder)"], @@ -25,7 +25,7 @@ "BEFORE opening a browser, GET /api/v1/auth/config and record features.ssoEnforced, features.sso, and the socialProviders/oidc provider list — this is the flag the client will honor", "open the login page in a fresh browser context and capture the FIRST paint (screenshot as early as render settles)", "inspect the first paint for: the SSO sign-in surface, the ABSENCE of the email/password form and self-registration, and the PRESENCE of the break-glass 'use a password' link (the spec keeps the break-glass password endpoint enabled — the env owner retains an escape hatch)", - "simulate a hung sign-in (e.g. an IdP that never redirects back) and wait out the watchdog window; screenshot the recovery state", + "simulate a hung sign-in REQUEST — the sign-in call the console makes never answers (e.g. route it to a black hole in the browser) — and wait out the console's watchdog, which covers only that request (20 s); screenshot the recovery state. ⛔ Not an IdP that never redirects back: once the browser has navigated to the IdP the console is no longer on the page, so no console watchdog can run", "restart the server WITHOUT enforcement (ssoOnlyMode off) and capture the login first paint again as the other side of the gate" ], "acceptance": [ @@ -44,13 +44,13 @@ { "clause": "self-registration is hidden under enforcement, but the break-glass password link remains — enforcement hides the local form, it does not brick the env owner", "oracle": "screenshot", - "verify": "no sign-up affordance; the 'use a password' break-glass link is present (AuthFeaturesConfigSchema's own contract for ssoEnforced)", + "verify": "no sign-up affordance; the 'use a password' break-glass link is present (AuthFeaturesConfigSchema's own contract for ssoEnforced). The sign-up half discriminates only on a boot whose posture otherwise offers the register link: on the stock invite_only showcase the link is withheld anyway, so its absence there is recorded, not scored — the break-glass half is scored on every boot", "evidence": "annotated login screenshot" }, { "clause": "a hung sign-in recovers via the watchdog rather than stranding the page", "oracle": "screenshot", - "verify": "after the watchdog window, the page offers a retry path (not a spinner forever)", + "verify": "after the watchdog window (20 s on the hung sign-in request), the page offers a retry path (not a spinner forever)", "evidence": "post-window screenshot" }, { @@ -72,7 +72,8 @@ ], "history": [ { "revision": 1, "date": "2026-08-07", "change": "initial import from #3358", "ref": "#3358" }, - { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" } + { "revision": 2, "date": "2026-08-07", "change": "expanded to deep-test contract: concrete steps, multi-clause acceptance, negatives, variants", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 3, "date": "2026-10-17", "change": "steps[4] and acceptance[2].verify and [3].verify corrected (checklist-accuracy findings of the identity-auth slice report on #23878 (6216631966)). The step's 'IdP that never redirects back' is outside the console's watchdog, which covers only the hung sign-in request (20 s) — the console is not on the page while the browser sits at the IdP — so the step now hangs the request itself. And the sign-up half of clause 2 cannot discriminate on the invite_only showcase, where the register link is withheld regardless of enforcement. Not loosened: the break-glass link and the retry path are still required", "ref": "#24036" } ] }, { @@ -155,7 +156,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": 7, + "revision": 8, "priority": "P1", "surface": "mixed", "personas": ["anonymous visitor", "provisioned user per method", "admin (for env configuration)"], @@ -169,15 +170,15 @@ "magic-link and passkeys: the AuthPluginConfigSchema flags still exist, but objectui ships NO login UI for either, so #7481 withdrew both from the /api/v1/auth/config payload (protocol 17) — features.magicLink / features.passkeys are now ABSENT by design, not false. objectui#4179 (the login-UI card) was CLOSED not_planned on 2026-09-24: the maintainer's 'no UI for now' stands until the login UI is scheduled. So the browser-lane variants have no environment to run in; record them blocked(dependency, objectui#4179 closed not_planned), never pass (QA run #21784 N3, parked on #22017 row 20). The flag-advertisement clause inverts here: seeing either key in the payload is itself a FAIL now, and magic-link's endpoints (/magic-link/send, /magic-link/verify) stay live and drivable without a UI", "the two spec files disagree on the device-flow paths (packages/spec/src/api/auth-endpoints.zod.ts: /device/request, /device/token, /device/approve vs packages/spec/src/system/auth-config.zod.ts: /device/code, /device/token, /device, /device/approve, /device/deny) — the LIVE better-auth surface serves POST /device/code, /device/token, /device/approve, /device/deny and GET /device (auth-route-ledger.ts BETTER_AUTH_MOUNTED_SURFACE); trust the live routes and file the doc divergence", "the password-reset path names ALSO diverge from the spec: AuthEndpointPaths.forgetPassword = '/forget-password' but the live catch-all serves POST /request-password-reset, POST /reset-password and GET /reset-password/:token (BETTER_AUTH_MOUNTED_SURFACE) — trust the live routes (this is the same divergence identity-auth.self-service-password-reset drives)", - "acceptance[0]'s SSO and device-authorization variants have no switch on the stock `os dev` → `os serve` path (QA run #21784, parked on #22017 row 20). The AuthPlugin the CLI injects takes only env-mapped plugins (packages/cli/src/commands/serve.ts: OS_AUTH_ADMIN, OS_AUTH_TWO_FACTOR, OS_AUTH_PASSWORD_REJECT_BREACHED, OS_AUTH_PHONE_NUMBER_ENABLED), and neither plugins.deviceAuthorization nor an oidcProviders map is one of them. Device authorization needs a host that constructs AuthPlugin itself with plugins.deviceAuthorization: true. The SSO plugin DOES have a boot switch, OS_SSO_ENABLED (packages/plugins/plugin-auth/src/auth-manager.ts, isSsoWired), but features.sso is advertised only once a provider is registered, and registering one needs a reachable external IdP. So the environment these variants need is a custom host (device) and a reachable IdP (SSO / OIDC). The QA container and CI have neither; record both blocked(environment)" + "acceptance[0]'s SSO and device-authorization variants have no switch on the stock `os dev` → `os serve` path (QA run #21784, parked on #22017 row 20). The AuthPlugin the CLI injects takes only env-mapped plugins (packages/cli/src/commands/serve.ts: OS_AUTH_ADMIN, OS_AUTH_TWO_FACTOR, OS_AUTH_PASSWORD_REJECT_BREACHED, OS_AUTH_PHONE_NUMBER_ENABLED), and neither plugins.deviceAuthorization nor an oidcProviders map is one of them. Device authorization needs a host that constructs AuthPlugin itself with plugins.deviceAuthorization: true. The SSO plugin DOES have a boot switch, OS_SSO_ENABLED (packages/plugins/plugin-auth/src/auth-manager.ts, isSsoWired), but features.sso is advertised only once a provider is registered, and registering one needs a reachable external IdP. So the environment these variants need is a custom host (device) and a reachable IdP (SSO / OIDC). A reachable IdP need not be external: generic OIDC is drivable against an IdP started on localhost (the identity-auth slice report on #23878 (6216631966)), so the generic-OIDC half is not blocked(environment) — only the device variant (no custom host) and the enterprise @better-auth/sso half (features.sso) stay blocked(environment)" ] }, "steps": [ "for each variant: boot with the method configured ON, GET /api/v1/auth/config, and record the advertisement (features.* flag, emailPassword block, socialProviders list)", "email+password: POST /api/v1/auth/sign-up/email (when sign-up enabled), POST /api/v1/auth/sign-in/email, GET /api/v1/auth/get-session, POST /api/v1/auth/sign-out — capture each response and the session cookie lifecycle", "phone+password: create a phone-carrying user with a known password, POST the phone sign-in (better-auth /sign-in/phone-number surface — 'always works' when the plugin is on per the spec), verify get-session identifies the user", - "phone OTP with NO SMS service configured: request /phone-number/send-otp and capture the loud 400 SMS_SERVICE_REQUIRED rejection (never a silent 200, never a hang, never a bare 500)", - "SSO/OIDC: with an oidcProviders[] entry configured, verify the login page shows the SSO button (features.sso is refined to 'usable' — ≥1 provider); drive the authorization-code round trip or cite the pinned OIDC dogfood test per rule 6", + "phone OTP with NO SMS service configured, on a boot with NODE_ENV=production: request /phone-number/send-otp and capture the loud 400 SMS_SERVICE_REQUIRED rejection (never a silent 200, never a hang, never a bare 500). On a development boot the SMS service falls back to its log transport, which delivers in dev (LogSmsTransport prints the body — packages/services/service-sms/src/sms-service.ts), so send-otp succeeds there and the no-SMS state never occurs", + "SSO/OIDC: the two halves surface differently. A generic oidcProviders[] entry is advertised as a socialProviders[] entry of type 'oidc' in /api/v1/auth/config (getPublicConfig, auth-manager.ts) — the list the login page's provider buttons read; features.sso tracks ONLY the @better-auth/sso plugin (isSsoWired), so a generic OIDC provider never sets it. Drive the generic-OIDC authorization-code round trip against a localhost IdP, or cite the pinned OIDC dogfood test per rule 6", "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", @@ -207,7 +208,7 @@ { "clause": "phone OTP without a deliverable SMS service fails LOUDLY (400 SMS_SERVICE_REQUIRED) — the capability degrades to a named error, never a silent success, a hang or a bare 500", "oracle": "api", - "verify": "the send-otp response is 400 with a JSON body whose code is SMS_SERVICE_REQUIRED (pinned by packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts); features.phoneNumberOtp is NOT advertised in /auth/config (only advertised when SMS is deliverable, #2780)", + "verify": "on a NODE_ENV=production boot, the send-otp response is 400 with a JSON body whose code is SMS_SERVICE_REQUIRED (pinned by packages/plugins/plugin-auth/src/phone-otp-no-sms-service-refusal.test.ts); features.phoneNumberOtp is NOT advertised in /auth/config (only advertised when SMS is deliverable, #2780)", "evidence": "the rejection + the /auth/config read" }, { @@ -258,11 +259,11 @@ "email+password (POST /api/v1/auth/sign-in/email, /sign-up/email, /sign-out, /get-session)", "phone+password (better-auth phone-number plugin sign-in surface)", "phone OTP sign-in + reset (requires SMS service; loud 400 SMS_SERVICE_REQUIRED on send-otp without — #2780)", - "enterprise SSO / generic OIDC (oidcProviders[] via genericOAuth; login button gated on usable providers)", + "enterprise SSO / generic OIDC (generic oidcProviders[] via genericOAuth surface as socialProviders[] of type 'oidc'; features.sso tracks only @better-auth/sso)", "social OAuth (socialProviders map, per-provider enabled)", "device authorization grant (RFC 8628 — CLI/TV login)", "two-factor (server-driven challenge, ADR-0069; cookie-lane completion pinned end-to-end by two-factor-lockout.dogfood.test.ts — arm with OS_AUTH_TWO_FACTOR=true BEFORE boot, the plugin list is resolved once at auth-manager construction; enrollment lifecycle owned by identity-auth.two-factor-*)", - "magic link (server endpoints live; no login UI and no advertised flag since #7481 — blocked, objectui#4179)", + "magic link (off on a stock boot — the routes answer 404 unless plugins.magicLink is enabled; no login UI and no advertised flag since #7481 — blocked, objectui#4179)", "passkeys (plugin flag accepted but nothing wired; no advertised flag since #7481 — blocked, objectui#4179)" ], "automated": { "kind": "e2e", "ref": "packages/qa/dogfood/test/oidc-authorization-code-flow.dogfood.test.ts" }, @@ -282,7 +283,8 @@ { "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": 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" } + { "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" }, + { "revision": 8, "date": "2026-10-17", "change": "knownGaps[3], steps[3] and [4], acceptance[3].verify and variants[3] and [7] corrected (checklist-accuracy findings of the identity-auth slice report on #23878 (6216631966)). The no-SMS state needs NODE_ENV=production: in development the SMS log transport delivers, so send-otp succeeds. A generic oidcProviders entry surfaces as a socialProviders[] entry of type 'oidc', while features.sso tracks only the @better-auth/sso plugin, so the SSO step's 'features.sso refined to usable' check could never see a generic provider. Magic-link is off on stock (404), not 'server endpoints live'. Generic OIDC is drivable against a localhost IdP, so only the device and enterprise-SSO halves stay blocked(environment). Not loosened: a phone OTP without SMS that succeeds, hangs or 500s on a production boot still FAILS", "ref": "#24036" } ] }, { @@ -290,7 +292,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": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": ["delegated_admin org member", "plain member", "tenant admin (setup only)"], @@ -303,12 +305,12 @@ }, "steps": [ "prefer the pin (rule 6): run packages/qa/dogfood/test/delegated-admin-invite.dogfood.test.ts and capture its output — it proves the three-way contract end to end over real HTTP", - "browser lane, as the delegated_admin: open the invite affordance and issue a MEMBER-role invitation; capture the request and the created sys_invitation row via the data API", + "browser lane, as the delegated_admin: open the invite affordance — the sys_invitation list at /_console/apps/com.objectstack.account/sys_invitation, which no navigation entry links to, so open it by URL — and issue a MEMBER-role invitation; capture the request and the created sys_invitation row via the data API", "as the same principal, attempt an ADMIN-role invitation; capture the refusal", "immediately read sys_invitation via the API and verify NO row (not even a pending one) was left behind by the refused attempt", "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", + "exercise cancel_invitation on the pending row as an org OWNER or ADMIN — cancel is owner/admin only, so the delegated_admin who issued the invitation is refused it; record that refusal too — and resend_invitation as the entitled persona, and verify each status/state change via the API", "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": [ @@ -345,7 +347,7 @@ { "clause": "invitation lifecycle state uses the spec's vocabulary and transitions honestly (pending → accepted/rejected/expired; cancel/resend behave)", "oracle": "api", - "verify": "row status is always one of the InvitationStatus enum values; the cancel/resend action's effect is visible in a fresh read", + "verify": "row status is always one of the InvitationStatus enum values; the cancel (by an owner/admin) and resend actions' effects are visible in a fresh read, and a delegated_admin's cancel is refused with the row unchanged", "evidence": "the before/after reads" }, { @@ -372,7 +374,8 @@ "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": 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) 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" } + { "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) 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" }, + { "revision": 4, "date": "2026-10-17", "change": "steps[1] and [6] and acceptance[5].verify corrected (checklist-accuracy findings of the identity-auth slice report on #23878 (6216631966)). cancel_invitation is owner/admin only, so 'the entitled persona' in steps[6] was ambiguous and the delegated_admin the item drives is refused it; and the invite affordance lives at /_console/apps/com.objectstack.account/sys_invitation, which no nav entry links to. Not loosened: the lifecycle clause still requires each transition to show in a fresh read", "ref": "#24036" } ] }, { @@ -380,7 +383,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": 7, + "revision": 8, "priority": "P1", "surface": "mixed", "personas": ["platform admin", "target user", "non-admin forger"], @@ -405,7 +408,7 @@ "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", "RULED (#9969, closed not_planned): sign the target in to establish a LIVE session, then as platform admin POST /api/v1/auth/admin/revoke-user-sessions for that target and record the refusal, 403 YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS. Then re-drive the target's PROTECTED authed request (e.g. GET /api/v1/data/OBJECT for an object the target could read a moment ago): it still answers 2xx, because the refused call moved nothing. If the route is ever re-mounted for an ObjectStack consumer, the same request must instead be refused mid-flight (acceptance[4]'s re-mount contract). Either way, do NOT score this off get-session's status code: an anonymous OR revoked get-session answers 401 with the ADR-0112 refusal envelope (code UNAUTHENTICATED, packages/plugins/plugin-auth/src/anonymous-session-refusal.ts), while a live session still answers 200 with { user, session }. One auth-route seam's status is not proof the AUTHORIZATION layer refuses the target's protected reads", "RULED (#9968, ruled B): as platform admin, POST /api/v1/auth/admin/set-role for the target and record the refusal, 403 YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE. Then re-read the target's sys_user row: the role scalar and positions[] are unchanged", - "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", + "impersonate the target from the admin surface — Impersonate is offered only in the Setup → Users LIST's row menu (the target's ⋯), not on the user's record page; 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", "RULED (#9969, closed not_planned): as platform admin, POST /api/v1/auth/admin/remove-user for a throwaway user that OWNS at least one showcase row and record the refusal, 403 YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS. Then confirm the user and the owned row are both still there. The engine cascade exemption the old step drove (#3023/#3048) has no public door left, so acceptance[5] scores it from its pin", "as a NON-admin, fire each of /admin/ban-user, /admin/list-users, /admin/create-user, /admin/set-role, /admin/remove-user, /admin/revoke-user-sessions directly and capture every refusal", "run the audit-trail pin and capture its output for the attribution clause" @@ -514,6 +517,12 @@ "date": "2026-10-06", "change": "partial-coverage gap of QA run #21784 parked on #22017 (row 15), re-pointed clauses. A4-A6 were blocked(dependency): set-role, revoke-user-sessions and remove-user are ruled not built for the platform admin (#9968 ruled B; #9969 closed not_planned). The clauses still asserted the positive outcomes, which the platform has ruled out. A4-A6 now assert the ruled refusals, each with its vendor code and a no-effect read, pinned by admin-platform-admin-standing.dogfood.test.ts. A5 and step 8 also keep the contract a re-mount must meet (revoke-user-sessions kills the live session mid-flight) and its non-status oracle: a PROTECTED request, never get-session, whose 401 UNAUTHENTICATED is the ADR-0112 refusal (the alignment plugin-auth's checklist-refusal-envelope-consistency.test.ts pins). A6's cascade exemption is scored from plugin-security's [#3023] test, since no public door deletes a user. Steps 2, 8, 9 and 11 (list-users is refused by the same ruling, record #21784's accuracy finding) and negatives 3-4 follow", "ref": "#22017" + }, + { + "revision": 8, + "date": "2026-10-17", + "change": "steps[9] names where Impersonate lives (checklist-accuracy finding of the identity-auth slice report on #23878 (6216631966)): only in the Users list row menu, so 'the admin surface' sent a runner to the record page, which does not offer it. Wording only", + "ref": "#24036" } ] }, @@ -522,7 +531,7 @@ "title": "Active-session list is owner-scoped, and each revoke primitive terminates exactly the sessions it names", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": ["user A (two devices)", "user B (unrelated, second live session)", "platform admin"], @@ -537,18 +546,18 @@ "steps": [ "sign user A in from two independent browser contexts (device-1, device-2), and separately sign user B in once — three live sessions total", "as A, GET /api/v1/auth/list-sessions (client auth.sessions.list) AND load the Account 'Active Sessions' surface (sys_session `mine` view, filter user_id = {current_user_id}); screenshot the list after it renders", - "cross-check the data-API read: GET /api/v1/data/sys_session as A returns ONLY A's rows — B's session must be invisible (RLS), even though sys_session is a shared managed-by-better-auth table", + "cross-check the data-API read: GET /api/v1/data/sys_session as A returns ONLY A's rows — B's session must be invisible (RLS), even though sys_session is a shared managed-by-better-auth table. RLS scopes the raw read by owner, not by liveness, so it also carries A's OWN revoked tombstones (revoked_at set) from earlier revokes: count A's live rows as those with revoked_at null, and judge the scoping by B's absence", "revoke device-2 specifically: POST /api/v1/auth/revoke-session keyed on that session's TOKEN (the object action revoke_session uses recordIdParam:'token' — better-auth keys off the token, not the row id); then issue an authed request from device-2 and capture the 401", "confirm device-1 is untouched: an authed request from device-1 still 2xx", "from device-1, invoke revoke-other-sessions (POST /api/v1/auth/revoke-other-sessions, the 'Sign out other devices' toolbar action): every OTHER session for A dies, device-1 survives; re-read list-sessions and confirm exactly one row (device-1) remains", - "as admin, open Setup → Sessions (all_sessions view) and confirm it lists rows across ALL users (A and B both present, user_id column shown), with the revoked_at / revoke_reason fields visible on terminated rows", + "as admin, open Setup → Sessions: the all_sessions view lists LIVE rows across ALL users (A and B both present, user_id column shown) and filters tombstones out; open its `revoked` view for the terminated rows, whose columns carry revoked_at and revoke_reason (sys-session.object.ts views: all_sessions, mine, revoked)", "attempt a direct write to sys_session via the data API (PATCH/DELETE /api/v1/data/sys_session/{id}) and capture the refusal — the table is read-only over the data API (apiMethods ['get','list'], writes 405 before 403)" ], "acceptance": [ { "clause": "the active-session list is owner-scoped: A's list-sessions and the Account sessions view show BOTH of A's sessions and NONE of B's — the shared sys_session table is RLS-filtered to the caller", "oracle": "api", - "verify": "GET /api/v1/auth/list-sessions and GET /api/v1/data/sys_session as A return A's two rows only; B's row id (known from B's own read) is absent", + "verify": "GET /api/v1/auth/list-sessions as A returns A's two live sessions only; GET /api/v1/data/sys_session as A returns A's rows only — its rows with revoked_at null are A's two live sessions, and any others are A's own tombstones — and B's row id (known from B's own read) is absent from both", "evidence": "A's list responses + B's row id proving the exclusion" }, { @@ -570,9 +579,9 @@ "evidence": "the post-call list + a 401 from a killed session" }, { - "clause": "the admin Sessions surface lists ALL users' sessions with revoke metadata: the all_sessions view shows rows for both A and B (user_id column) and surfaces revoked_at / revoke_reason on terminated rows", + "clause": "the admin Sessions surface lists ALL users' sessions with revoke metadata: the all_sessions view shows live rows for both A and B (user_id column), and the revoked view surfaces revoked_at / revoke_reason on terminated rows", "oracle": "screenshot", - "verify": "Setup → Sessions shows cross-user rows; a revoked row shows revoked_at set and a NON-EMPTY revoke_reason. ⚠️ It is not a closed vocabulary: sys-session.object.ts declares revoke_reason as Field.text (maxLength 64) whose description lists idle_timeout / absolute_max / concurrent_cap only as EXAMPLES, and revoke-other-sessions was observed writing user_revoked — a legitimate value, not a violation. Score the field's presence and plausibility; a value outside the examples is a finding only if it is empty or meaningless", + "verify": "Setup → Sessions (all_sessions) shows cross-user live rows; in its revoked view a terminated row shows revoked_at set and a NON-EMPTY revoke_reason. ⚠️ It is not a closed vocabulary: sys-session.object.ts declares revoke_reason as Field.text (maxLength 64) whose description lists idle_timeout / absolute_max / concurrent_cap only as EXAMPLES, and revoke-other-sessions was observed writing user_revoked — a legitimate value, not a violation. Score the field's presence and plausibility; a value outside the examples is a finding only if it is empty or meaningless", "evidence": "the admin sessions screenshot + a data-API read of a revoked row" }, { @@ -596,7 +605,8 @@ ], "history": [ { "revision": 1, "date": "2026-08-08", "change": "new item: owner-scoped active-session list (RLS) + per-primitive revoke semantics (revoke-session by token, revoke-other-sessions keeps current) + admin all-sessions view with revoked_at/revoke_reason, grounded in the auth route ledger and sys_session object (PENDING-GAPS §B)", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-08-18", "change": "softened clause 5, which described revoke_reason as a closed {idle_timeout, absolute_max, concurrent_cap, admin} vocabulary. packages/platform-objects/src/identity/sys-session.object.ts declares it as free text (Field.text, maxLength 64) and its description names those as examples with a trailing ellipsis; the observed value after revoke-other-sessions is user_revoked, which the clause as written scores as a violation (#9467 CF-7). Either the clause softens or the field declares a select — softened here, since the ledger cannot change the declaration", "ref": "#9386" } + { "revision": 2, "date": "2026-08-18", "change": "softened clause 5, which described revoke_reason as a closed {idle_timeout, absolute_max, concurrent_cap, admin} vocabulary. packages/platform-objects/src/identity/sys-session.object.ts declares it as free text (Field.text, maxLength 64) and its description names those as examples with a trailing ellipsis; the observed value after revoke-other-sessions is user_revoked, which the clause as written scores as a violation (#9467 CF-7). Either the clause softens or the field declares a select — softened here, since the ledger cannot change the declaration", "ref": "#9386" }, + { "revision": 3, "date": "2026-10-17", "change": "steps[2] and [6] and acceptance[0].verify and [4] (clause, verify) corrected (checklist-accuracy findings of the identity-auth slice report on #23878 (6216631966)). revoked_at and revoke_reason are columns of the sys_session `revoked` view — all_sessions filters tombstones out — so the step looked for revoke metadata on a view that never shows a terminated row. And the owner's raw /data read carries their own tombstones, so 'returns A's two rows only' miscounted after any revoke; live rows are those with revoked_at null. Not loosened: B's rows must still be absent from A's reads, and a terminated row must still show a non-empty reason", "ref": "#24036" } ] }, { @@ -604,22 +614,22 @@ "title": "Personal API key: minted show-once on Connect-an-Agent, authenticates as its owner, revoke kills it and restore brings it back — mine-view scoped", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": ["a signed-in member (key owner)", "platform admin", "the key itself (machine caller via x-api-key)"], "fixtures": { "app": "showcase", "requires": [ - "the developer Integrations page (AgentConnectSection) reachable for the signed-in user — POST /api/v1/keys is the mint route (client keys.create) and it is not admin-gated (the key acts as the caller)", - "at least one apiEnabled showcase data object to call with the key (e.g. showcase_task)" + "the developer Integrations page (AgentConnectSection) reachable for the signed-in user, at /_console/apps/com.objectstack.account/developer/integrations — POST /api/v1/keys is the mint route (client keys.create) and it is not admin-gated (the key acts as the caller)", + "a data object whose rows DIFFER between the key's owner and an admin, so the owner-view comparison can discriminate: sys_session (owner-scoped by RLS — a member reads only their own sessions, an admin reads all). ⛔ Not showcase_task: it returns the same rows to a member and an admin, so a key that bypassed RLS would read identically" ] }, "steps": [ - "as a signed-in member, open the developer Integrations page → 'Connect an AI agent (MCP)' (AgentConnectSection), type a key name and click Generate key; screenshot the show-once dialog", + "as a signed-in member, open the developer Integrations page (/_console/apps/com.objectstack.account/developer/integrations) → 'Connect an AI agent (MCP)' (AgentConnectSection), type a key name and click Generate key; screenshot the show-once dialog", "capture the mint response: POST /api/v1/keys returns data.key (the raw secret) exactly once, plus id / name / prefix", "record the raw secret, close the dialog, then re-open the API Keys list (sys_api_key `mine` view) and confirm the row shows only the prefix — the raw key is never shown again (the stored `key` column is hashed + hidden)", - "call a data route as the machine caller: GET /api/v1/data/showcase_task with header x-api-key: (NOT a session cookie/Bearer) and capture the 200 + that the rows returned are the OWNER's row-level-security view (the key acts as the user)", + "call a data route as the machine caller: GET /api/v1/data/sys_session with header x-api-key: RAW_SECRET (NOT a session cookie/Bearer) and capture the 200 + that the rows returned are the OWNER's row-level-security view (the key acts as the user): only the owner's own sessions, as the owner reads with a session — while an admin's session read of the same route returns other users' rows too", "revoke the key via the row action revoke_api_key (PATCH /api/v1/data/sys_api_key/{id} bodyExtra {revoked:true}); immediately re-call the data route with x-api-key and capture the 403 whose body's error.code is API_KEY_REVOKED and which carries no rows (a recognised key its own lifecycle refuses — only an UNKNOWN key gets the generic 401)", "restore via restore_api_key (PATCH … {revoked:false}); re-call and capture the 200 again — the lifecycle is reversible", "mine-view scoping: sign in as a DIFFERENT member and confirm the sys_api_key `mine` view (filter user_id={current_user_id}) does NOT list the first user's key; then as admin open Setup → API Keys (the `all_keys` view, nav gated on manage_platform_settings) and confirm every user's key is listed", @@ -635,7 +645,7 @@ { "clause": "the key authenticates as its owner: x-api-key on a data route returns 200 and the row set is the OWNER's RLS view — a machine credential, carrying the user's permissions, not a superuser bypass", "oracle": "api", - "verify": "GET /api/v1/data/showcase_task with x-api-key returns 200; the rows match what the owner sees with a session (not more)", + "verify": "GET /api/v1/data/sys_session with x-api-key returns 200; every row's user_id is the owner, the row set equals the owner's session read (not more), and it is smaller than the admin's read of the same route — the control that shows the route discriminates", "evidence": "the x-api-key request trace + a session-read comparison" }, { @@ -679,7 +689,8 @@ ], "history": [ { "revision": 1, "date": "2026-08-08", "change": "new item: personal API key mint (show-once, POST /api/v1/keys) → authenticate-as-owner via x-api-key → revoke 401 → restore 200, plus mine-view vs admin all-view scoping, grounded in AgentConnectSection + sys_api_key object (PENDING-GAPS §B)", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-10-16", "change": "stale oracle re-pointed (#23992): step 4 and acceptance[2] expected the post-revoke x-api-key call to answer 401, but since the API-key lifecycle (#23367 ruling A, executed by #23541) a recognised revoked key is refused 403 with error.code API_KEY_REVOKED (packages/core/src/security/api-key.ts#lifecycleRefusalOf) and only an unknown key gets the generic 401, so the item scored a correct runtime red. The step and the clause now capture the 403, the code and the absence of rows, and pin none of the refusal's wording (#23944 rewords it). Not loosened: a 200, a generic 401 and a 403 with any other code each still FAIL (the negative names both directions). Step 5 (restore → 200) is unchanged; source gains the refusal's decision and relay sites", "ref": "#23992" } + { "revision": 2, "date": "2026-10-16", "change": "stale oracle re-pointed (#23992): step 4 and acceptance[2] expected the post-revoke x-api-key call to answer 401, but since the API-key lifecycle (#23367 ruling A, executed by #23541) a recognised revoked key is refused 403 with error.code API_KEY_REVOKED (packages/core/src/security/api-key.ts#lifecycleRefusalOf) and only an unknown key gets the generic 401, so the item scored a correct runtime red. The step and the clause now capture the 403, the code and the absence of rows, and pin none of the refusal's wording (#23944 rewords it). Not loosened: a 200, a generic 401 and a 403 with any other code each still FAIL (the negative names both directions). Step 5 (restore → 200) is unchanged; source gains the refusal's decision and relay sites", "ref": "#23992" }, + { "revision": 3, "date": "2026-10-17", "change": "fixtures.requires[0] and [1], steps[0] and [3] and acceptance[1].verify re-pointed (checklist-accuracy residual of the identity-auth slice report on #23878 (6216631966), after PR #24012). showcase_task returns the same rows to a member and an admin, so the owner-view comparison could not tell an RLS-scoped key from a bypass; sys_session is owner-scoped, so the key's read must equal the owner's and fall short of the admin's. The Integrations page is named by its path, /_console/apps/com.objectstack.account/developer/integrations. Not loosened: a key that reads more than its owner still FAILS", "ref": "#24036" } ] }, { @@ -771,7 +782,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": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": ["tenant admin / org owner", "a target org member", "a non-admin org member (forger)"], @@ -789,7 +800,7 @@ "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", + "remove a member: POST /api/v1/auth/organization/remove-member (organizations.removeMember); confirm the member's org-scoped access SHRINKS through a request that is answered BECAUSE of membership of that org — e.g. GET /api/v1/auth/organization/get-full-organization?organizationId=ORG, or POST /api/v1/auth/organization/set-active for it — succeeding before and refused 403 USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION after. Read the after-side on a FRESH sign-in: remove-member also revokes the member's live sessions (revoke_reason organization_membership_ended), so the old session answers 401 to every request and discriminates nothing. ⛔ Not a data read: on the stock `single` posture the `everyone` baseline grants survive remove-member, so an ex-member still reads the same showcase rows and the check could not discriminate", "create a team + add members: POST /api/v1/auth/organization/create-team (organizations.teams.create), then POST /api/v1/auth/organization/add-team-member (organizations.teams.addMember) for two users; read sys_team_member and confirm two join rows exist (unique on team_id+user_id)", "both-sides gate: as a NON-admin member, fire update-member-role / remove-member / update / create-team / add-team-member directly and capture each server refusal; screenshot that the management affordances are absent in the non-admin's UI" ], @@ -821,7 +832,7 @@ { "clause": "remove-member shrinks access: after removal the ex-member is refused a resource they could reach as a member — removal is an authorization change, not just a roster edit", "oracle": "api", - "verify": "an org-scoped request that succeeded for the member returns a refusal after remove-member", + "verify": "an org-membership-scoped request (get-full-organization or set-active for that org) that succeeded for the member is refused 403 USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION after remove-member, read on a fresh sign-in (the member's pre-removal sessions are revoked, organization_membership_ended, and answer 401 to everything); a data read that the `everyone` baseline still serves is not this oracle", "evidence": "the before/after member requests" }, { @@ -857,7 +868,8 @@ "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": 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" }, - { "revision": 3, "date": "2026-09-12", "change": "PROVENANCE, no clause change: #16270 measured that no object under packages/platform-objects/src/identity/ declares Field.relatedList and read the Members/Invitations/Teams claim here as possibly stale. It is not stale — the strip is declared by SysOrganizationDetailPage (packages/platform-objects/src/pages/sys-organization.page.ts), a slotted page whose slots.tabs override replaces the synthesized strip. Added that file as a source entry so the next reader does not have to re-derive it. Steps, acceptance clauses, oracles and negatives are unchanged", "ref": "#16270" } + { "revision": 3, "date": "2026-09-12", "change": "PROVENANCE, no clause change: #16270 measured that no object under packages/platform-objects/src/identity/ declares Field.relatedList and read the Members/Invitations/Teams claim here as possibly stale. It is not stale — the strip is declared by SysOrganizationDetailPage (packages/platform-objects/src/pages/sys-organization.page.ts), a slotted page whose slots.tabs override replaces the synthesized strip. Added that file as a source entry so the next reader does not have to re-derive it. Steps, acceptance clauses, oracles and negatives are unchanged", "ref": "#16270" }, + { "revision": 4, "date": "2026-10-17", "change": "steps[6] and acceptance[4].verify name an org-membership-scoped oracle (checklist-accuracy finding of the identity-auth slice report on #23878 (6216631966)). On the stock `single` posture the `everyone` baseline reads survive remove-member, so 'a resource they could read as a member now refuses' found no data resource to refuse; the oracle is now a request answered because of membership of that org, read on a fresh sign-in — the re-run on #24036 found remove-member revoking the member's live sessions (organization_membership_ended), while the fresh session still read 5 showcase rows and drew 403 USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION from get-full-organization and set-active. Not loosened: removal must still refuse it", "ref": "#24036" } ] }, { @@ -946,23 +958,23 @@ "title": "Forgot-password: request → token captured at the dev mail transport → reset; old password refused, expired/reused token refused loudly", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": ["anonymous visitor (holds no session)", "the account owner (before and after reset)"], "fixtures": { "app": "showcase", "requires": [ - "email+password sign-in enabled and an email transport configured — for a hermetic run use the dev `log` mail transport (EMAIL_TRANSPORT_PROVIDERS 'log' → LogTransport) so the reset email (and its token) is captured in the transport output rather than actually sent", + "email+password sign-in enabled and an email transport configured — a stock boot sends through the dev `log` transport, so nothing leaves the box; the mail is read from its sys_email row (admin GET /api/v1/data/sys_email — body_text / body_html hold the rendered body and its link): the dev `log` transport is not an env switch (EMAIL_TRANSPORT_PROVIDERS is a const list of what the package can build, packages/plugins/plugin-email/src/transports/index.ts) and LogTransport logs only to, subject and hasText/hasHtml — never the body or the link (packages/plugins/plugin-email/src/email-service.ts)", "a target user with a known current password" ], "knownGaps": [ - "capturing the reset TOKEN requires reading it from the dev mail transport (LogTransport) output — if the deployment sends via a real provider with no capture hook, the token-capture step is blocked(fixture); the request/refusal clauses that don't need the token still run" + "capturing the reset TOKEN means reading the reset mail's sys_email row (admin GET /api/v1/data/sys_email, newest row to the requester) — the LogTransport output carries no link. A deployment whose provider persists no sys_email row leaves the token-capture step blocked(fixture); the request/refusal clauses that don't need the token still run" ] }, "steps": [ "as anonymous, request a reset: POST /api/v1/auth/request-password-reset with the target email (NOTE the live route is request-password-reset / reset-password — the spec's AuthEndpointPaths says /forget-password, a divergence; trust the live server per RUNNER, and file the doc divergence)", - "capture the reset email at the dev mail transport (LogTransport) and extract the token (the link is GET /api/v1/auth/reset-password/:token)", + "capture the reset email: as admin, read the newest sys_email row addressed to the requester (GET /api/v1/data/sys_email; to_addresses reads 'NAME ', so match on the address it contains, not on equality) and extract the token from body_text / body_html (the link is GET /api/v1/auth/reset-password/:token). The LogTransport line in the server log shows only to and subject — it never carries the link", "complete the reset: POST /api/v1/auth/reset-password with the token + a new password; capture the response", "sign in with the NEW password (2xx) and confirm the OLD password is now refused", "reuse the SAME token a second time (POST /api/v1/auth/reset-password again) and capture the loud refusal — a consumed token must not reset again", @@ -974,8 +986,8 @@ { "clause": "the request issues a real reset artifact: POST /api/v1/auth/request-password-reset produces an email at the dev transport carrying a reset token/link (GET /api/v1/auth/reset-password/:token)", "oracle": "log", - "verify": "the LogTransport output for the request contains the reset link with a token; the request response is a non-leaking 2xx/accepted", - "evidence": "the captured transport output" + "verify": "the sys_email row the request wrote (to = the requester) carries the reset link with a token in its body; the request response is a non-leaking 2xx/accepted", + "evidence": "the captured sys_email row (to, subject, the link with the token redacted after use)" }, { "clause": "the token completes the reset and rotates the credential: POST /api/v1/auth/reset-password with the token + new password succeeds, the new password signs in and the old one is refused", @@ -1015,7 +1027,8 @@ "packages/platform-objects/src/identity/sys-account.object.ts#previous_password_hashes (previous_password_hashes ring — ADR-0069 D1 reuse-prevention)" ], "history": [ - { "revision": 1, "date": "2026-08-08", "change": "new item: forgot-password round trip (request-password-reset → dev-transport token capture → reset-password), old-password-refused/new-works, single-use + expiry refusals, anti-enumeration, with the token-capture dev-mail dependency recorded as a knownGap (PENDING-GAPS §B)", "ref": "claude/platform-test-checklist-ocwugl" } + { "revision": 1, "date": "2026-08-08", "change": "new item: forgot-password round trip (request-password-reset → dev-transport token capture → reset-password), old-password-refused/new-works, single-use + expiry refusals, anti-enumeration, with the token-capture dev-mail dependency recorded as a knownGap (PENDING-GAPS §B)", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-10-17", "change": "fixtures.requires[0], knownGaps[0], steps[1] and acceptance[0] (verify, evidence) re-pointed to the capture oracle that exists (checklist-accuracy finding of the identity-auth slice report on #23878 (6216631966)). EMAIL_TRANSPORT_PROVIDERS is a const list, not an env switch, and LogTransport logs only to / subject / hasText / hasHtml, so 'capture the token at the dev transport output' named an output that never carries the link. The reset mail's sys_email row holds the rendered body, and the slice read the token there. Not loosened: the clause still requires a real mail carrying a working tokened link", "ref": "#24036" } ] }, { @@ -1023,7 +1036,7 @@ "title": "OAuth provider: register an app (secret shown once), run the authorization-code consent loop — approve mints tokens + a consent record, deny mints none", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "mixed", "personas": ["an org admin registering the OAuth client", "the resource-owner user granting/denying consent"], @@ -1040,7 +1053,7 @@ }, "steps": [ "wiring precheck: on the stock boot, confirm the embedded AS is up — GET /.well-known/oauth-authorization-server answers 200 with authorization/token endpoints under /api/v1/auth/oauth2/*, and the boot env sets neither OS_MCP_SERVER_ENABLED nor OS_OIDC_PROVIDER_ENABLED falsy; record both facts before the first oauth2 call", - "as admin, register an OAuth client with NO external IdP: either the console Setup → OAuth Applications create action (posts /api/v1/auth/sys-oauth-application/register) or POST /api/v1/auth/oauth2/create-client (client oauth.applications.register); capture the response and the client_secret — revealed ONCE at registration", + "as admin, register an OAuth client with NO external IdP: either the console Setup → OAuth Applications create action (posts /api/v1/auth/sys-oauth-application/register) or POST /api/v1/auth/oauth2/create-client (client oauth.applications.register); register it with an https, non-loopback redirect URI (e.g. https://app.example.test/callback): create-client defaults to client type 'web', which refuses http and loopback redirect URIs; capture the response and the client_secret — revealed ONCE at registration", "re-read the client via GET /api/v1/auth/oauth2/get-client and confirm the secret is NOT returned again (only client_id / public metadata)", "begin the authorization-code flow: GET /api/v1/auth/oauth2/authorize with the client_id, redirect_uri, scope and state; as the resource owner, land on the consent page — the console's /oauth/consent route (auth-manager.ts consentPage → OAuthConsentPage, objectui apps/console/src/App.tsx) — and screenshot the requested-scopes list", "APPROVE: POST /api/v1/auth/oauth2/consent (oauth.consent) accept; follow the redirect, exchange the code at POST /api/v1/auth/oauth2/token, and capture the issued access/refresh tokens", @@ -1110,7 +1123,8 @@ ], "history": [ { "revision": 1, "date": "2026-08-08", "change": "new item: OAuth client registration (secret once) + authorization-code consent loop (approve mints tokens + consent record, deny mints none, recorded consent short-circuits), mine-view scoped; blocked(fixture) pending a configured oidcProvider flow (PENDING-GAPS §C)", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-08-30", "change": "UNBLOCKED — the blocked(fixture) claim was stale, verified against source: the embedded authorization server is ON BY DEFAULT (resolveOidcProviderEnabled falls through to the MCP surface default, which is TRUE when OS_MCP_SERVER_ENABLED is unset), so oauth2/{authorize,token,consent,create-client} mount on every stock boot with consent rendered at the console's /oauth/consent, and a client registers with no external IdP (console create action → sys-oauth-application/register, SDK create-client, or DCR). Revision 1 had conflated platform-as-provider (this item) with an external IdP (what linked-accounts-social needs — that one stays blocked). Removed blocked, added the wiring precheck + a both-sides default-ON/off-switch clause, recorded the runtime caveat (confirm OS_MCP_SERVER_ENABLED / OS_OIDC_PROVIDER_ENABLED are not set falsy)", "ref": "#sweep-2026-08-30" } + { "revision": 2, "date": "2026-08-30", "change": "UNBLOCKED — the blocked(fixture) claim was stale, verified against source: the embedded authorization server is ON BY DEFAULT (resolveOidcProviderEnabled falls through to the MCP surface default, which is TRUE when OS_MCP_SERVER_ENABLED is unset), so oauth2/{authorize,token,consent,create-client} mount on every stock boot with consent rendered at the console's /oauth/consent, and a client registers with no external IdP (console create action → sys-oauth-application/register, SDK create-client, or DCR). Revision 1 had conflated platform-as-provider (this item) with an external IdP (what linked-accounts-social needs — that one stays blocked). Removed blocked, added the wiring precheck + a both-sides default-ON/off-switch clause, recorded the runtime caveat (confirm OS_MCP_SERVER_ENABLED / OS_OIDC_PROVIDER_ENABLED are not set falsy)", "ref": "#sweep-2026-08-30" }, + { "revision": 3, "date": "2026-10-17", "change": "steps[1] names a redirect URI the default client type accepts (checklist-accuracy finding of the identity-auth slice report on #23878 (6216631966)): create-client defaults to type 'web', which refuses http and loopback redirect URIs, so a runner registering a localhost callback met a refusal before the consent loop began", "ref": "#24036" } ] }, { @@ -1118,7 +1132,7 @@ "title": "Linked accounts: link a social/OIDC identity through the redirect round-trip → a sys_account row appears in mine-view; unlink removes it; provider-less boot degrades honestly", "since": "v16", "status": "active", - "revision": 4, + "revision": 5, "priority": "P2", "surface": "mixed", "personas": ["a signed-in user linking a second identity", "the same user after unlinking"], @@ -1135,11 +1149,11 @@ }, "steps": [ "as a signed-in user, open the Account app → Linked Accounts (sys_account `mine` view, filter user_id={current_user_id}); screenshot the initial link set", - "link through the AUTHENTICATED link door: with the user's session, POST /api/v1/auth/link-social {provider: '', callbackURL: '/_console/apps/account/sys_account'} — it answers the provider's authorization URL; navigate the browser to that returned URL (full-page navigation, NOT XHR, so the OAuth round-trip and its state cookie work), complete the provider round-trip and land back on Linked Accounts. ⛔ Do not link through the sign-in door (/api/v1/auth/sign-in/social): that signs in, it does not link to the current user", + "link through the AUTHENTICATED link door: with the user's session, POST /api/v1/auth/link-social {provider: '', callbackURL: '/_console/apps/account/sys_account'} — it answers the provider's authorization URL; navigate the browser to that returned URL (full-page navigation, NOT XHR, so the OAuth round-trip and its state cookie work), complete the provider round-trip and land back on Linked Accounts — the callbackURL above lands on the sys_account list's 'All' tab, not the caller's own view, so read the new row from the mine view (steps[2]), not from where the callback lands. ⛔ Do not link through the sign-in door (/api/v1/auth/sign-in/social): that signs in, it does not link to the current user", "confirm a sys_account row now exists for that provider in the caller's mine-view (provider_id = the provider, user_id = the caller) — (provider_id, account_id) is the whole account identity; there is no issuer to check, sys_account.issuer retired with better-auth 1.7.3 (packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts)", "unlink it: the unlink_account action → POST /api/v1/auth/unlink-account with accountId = the sys_account ROW id (better-auth 1.7 keys on the row id); confirm the row is gone from mine-view", "both-sides / degradation: on a boot with NO provider configured, confirm the Linked Accounts surface degrades honestly — any link affordance is absent or names the missing provider, rather than offering a link that dead-ends (open-edition honest-degradation posture)", - "confirm sys_account is read-only over the data API — a forged direct insert/delete is refused (apiMethods ['get','list'], writes 405)" + "confirm sys_account is read-only over the data API — a forged direct insert/delete is refused (apiMethods ['get','list'] — declared in sys-account.object.ts and served so by the meta read; the read-family aggregate and export routes pass that method gate and meet their own checks instead, a bare aggregate 400 VALIDATION_ERROR and export 403 EXPORT_NOT_PERMITTED, never 405; writes 405)" ], "acceptance": [ { @@ -1163,13 +1177,13 @@ { "clause": "a provider-less boot degrades honestly: the link affordance is absent or names the missing provider — never a link affordance that dead-ends", "oracle": "screenshot", - "verify": "with no provider configured, the Linked Accounts surface shows no dead link action (or an explicit unavailable state)", + "verify": "with no provider configured, the Linked Accounts surface shows no dead link action (or an explicit unavailable state). This does NOT discriminate on its own: sys_account carries no link action on ANY boot — link_social is retired (sys-account.object.ts: 'There is deliberately no LINK action') — so the affordance's absence is recorded, not scored as proof of degradation; a link affordance that appears and dead-ends is still the FAIL", "evidence": "the provider-less screenshot" }, { "clause": "sys_account is read-only over the data API — links are mutated only through the auth endpoints, not raw row writes", "oracle": "api", - "verify": "a forged POST/DELETE to /api/v1/data/sys_account returns 405 (apiMethods ['get','list'], identity write guard ADR-0092 D2)", + "verify": "a forged POST/DELETE to /api/v1/data/sys_account returns 405 (apiMethods ['get','list'] — reads only; the aggregate and export read routes are not method-refused; identity write guard ADR-0092 D2)", "evidence": "the forged-write response" } ], @@ -1192,7 +1206,8 @@ { "revision": 1, "date": "2026-08-08", "change": "new item: social/OIDC account linking round-trip → sys_account mine-view row, unlink removal, provider-less honest degradation; blocked(fixture) pending a configured IdP (PENDING-GAPS §C)", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-09-23", "change": "the 'issuer stamped' requirement is withdrawn from both the step and the first acceptance clause's verify: better-auth 1.7.3 rolled the issuer-scoped account identity back and sys_account.issuer retired with it, so nothing stamps it and the old verify failed a healthy system. The reason is kept — the step now says (provider_id, account_id) is the whole identity and names the retirement's migration entry in packages/spec — and the verify states that an issuer-less row is the expected shape", "ref": "#19217" }, { "revision": 3, "date": "2026-10-05", "change": "blocked lifted: run #21845 passed the link, mine-view, unlink and read-only clauses against a local authorization-code + PKCE OIDC provider registered through the auth manager's applyConfigPatch from a scratch app plugin ('auth:configure' fires too early); the provider recipe is written into fixtures.requires, not committed as code, and the missing-IdP gap is kept as a CLOSED-for-runs knownGap. Step 2 now links through the authenticated POST /api/v1/auth/link-social and navigates to the URL it returns — the old step drove the sign-in door, which signs in rather than links. The step, the provider-less clause and its negative no longer name a specific sys_account action: they describe the link affordance, so the clause indices stay stable while the action itself is handled on its own card. The user's nav label is 'Linked Accounts' in the Account app (nav_account_linked); 'Identity Links' is the Setup app's admin entry (card #21851 entry 3)", "ref": "#21845" }, - { "revision": 4, "date": "2026-10-16", "change": "PROVENANCE, no clause change: #23803 removed Setup's nav_accounts ('Identity Links') entry, so the source anchor that cited it no longer resolved. Re-pointed it at SysUserDetailPage, whose Linked Accounts tab (a related list over sys_account keyed on user_id) is where an administrator reaches a user's identity links now. Steps, acceptance clauses, oracles and negatives are unchanged: they drive the user's Account-app surface, which did not move", "ref": "#23803" } + { "revision": 4, "date": "2026-10-16", "change": "PROVENANCE, no clause change: #23803 removed Setup's nav_accounts ('Identity Links') entry, so the source anchor that cited it no longer resolved. Re-pointed it at SysUserDetailPage, whose Linked Accounts tab (a related list over sys_account keyed on user_id) is where an administrator reaches a user's identity links now. Steps, acceptance clauses, oracles and negatives are unchanged: they drive the user's Account-app surface, which did not move", "ref": "#23803" }, + { "revision": 5, "date": "2026-10-17", "change": "steps[1] and [5] and acceptance[3].verify and [4].verify corrected (checklist-accuracy findings of the identity-auth slice report on #23878 (6216631966)). The slice read sys_account's live methods as [get, list, aggregate, export]; the re-run on #24036 found the meta read still serving ['get','list'] while the aggregate and export routes pass the method gate (400 VALIDATION_ERROR / 403 EXPORT_NOT_PERMITTED, never 405), so the step now says both — reads only either way, and writes still 405; link_social is retired, so a provider-less boot's missing link action is the same absence every boot shows and clause 3 cannot discriminate on it; and the link callback lands on the 'All' tab. Not loosened: a forged write must still answer 405", "ref": "#24036" } ] }, { @@ -1291,7 +1306,7 @@ "title": "TOTP enrollment reveal: /two-factor/enable returns a parseable otpauth:// URI + backup codes, the declared reveal dialog's paths resolve against that exact shape, and the live re-reveal endpoint's behavior is recorded", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": ["a signed-in user with a known password enrolling their own second factor"], @@ -1313,7 +1328,7 @@ "parse the returned totpURI: it must be an otpauth://totp/ URI whose label identifies the issuer + account (the app/user), whose secret query param base32-decodes cleanly (use the copied helper), and whose algorithm/digits/period params — where present — are the sane RFC defaults (SHA1/6/30)", "assert backupCodes is a non-empty array of strings and record its size/format", "cross-check the declared reveal contract against the live shape: sys_two_factor.enable_two_factor's resultDialog.fields[].path values ('totpURI', 'backupCodes' — sys-two-factor.object.ts) are dot paths into result.data (action.zod.ts resultDialog contract); confirm each resolves against the response the API actually returned", - "probe the re-reveal endpoint: POST /api/v1/auth/two-factor/get-totp-uri (POST — auth-route-ledger.ts; the mounted-surface list is publication-not-liveness per #7735, so drive it, don't infer) with { password }, and record the actual behavior: status, whether it demands the password, and whether it returns the SAME secret as enable did — the 'shown only once' promise in the enable dialog copy (sys-two-factor.object.ts) does not account for this endpoint existing", + "probe the re-reveal endpoint: POST /api/v1/auth/two-factor/get-totp-uri (POST — auth-route-ledger.ts; the mounted-surface list is publication-not-liveness per #7735, so drive it, don't infer) with { password }, and record the actual behavior: status, whether it demands the password, and whether it returns the SAME secret as enable did — the enable dialog's 'shown only once' copy (sys-two-factor.object.ts: 'The backup codes are shown only once.') promises it for the BACKUP CODES only, not for the QR / secret, so a re-reveal of the secret contradicts no copy; record the behaviour all the same", "read the navigable surface's declaration: the sys_user action metadata (meta surface) for enable_two_factor — record its resultDialog (totpURI qrcode + backupCodes code-list + acknowledge, no successMessage) and resolve BOTH of its paths against the captured enable response, exactly as step 6 does for sys_two_factor", "negative lane: POST /two-factor/enable with a WRONG password (refused, no sys_two_factor row created) and as an anonymous caller (401)" ], @@ -1343,7 +1358,7 @@ "evidence": "the path-by-path resolution table against the captured response" }, { - "clause": "OBSERVE-AND-FLAG — the live re-reveal endpoint is recorded, not assumed: POST /api/v1/auth/two-factor/get-totp-uri is a mounted route that can hand the totpURI back AFTER enrollment, which the dialog's 'shown only once' copy does not account for. The run records its actual behavior (password-gated? same secret re-revealed? works before and after verification?) — this clause is satisfied by an accurate record, not by any particular outcome", + "clause": "OBSERVE-AND-RECORD — the live re-reveal endpoint is recorded, not assumed: POST /api/v1/auth/two-factor/get-totp-uri is a mounted route that hands the totpURI back AFTER enrollment — measured password-gated and returning the SAME secret (the identity-auth slice report on #23878 (6216631966)). The enable dialog's 'shown only once' copy covers the backup codes only, so the re-reveal breaks no promise the console makes; whether a re-revealable TOTP secret is accepted design is a ruling owed, not this clause's verdict. The run records the behaviour (password-gated? same secret? before and after verification?) — this clause is satisfied by an accurate record, not by any particular outcome", "oracle": "api", "verify": "drive POST /two-factor/get-totp-uri with and without the correct password, before and after the enrollment is verified; record each status and whether the returned URI carries the same secret. If it re-reveals with NO password gate, that is an authentication finding — RUNNER rule 2's carve-out applies (existence published, recipe withheld)", "evidence": "the recorded behavior table (secrets redacted)" @@ -1378,6 +1393,12 @@ "date": "2026-10-04", "change": "A6, step 8 and the browser-lane knownGap re-pointed: sys_user.enable_two_factor now declares a resultDialog (totpURI qrcode + backupCodes code-list + acknowledge, no successMessage — sys-user.object.ts enable_two_factor), so A6 asserts the declaration and resolves its paths against the live enable response instead of flagging a toast-only gap; the knownGap is reworded (the navigable surface declares the reveal; pixels stay objectui's ActionResultDialog, scored on the browser lane only). Stale clause. Source 266654d82e (#11110)", "ref": "#21735" + }, + { + "revision": 3, + "date": "2026-10-17", + "change": "steps[6] and acceptance[4].clause corrected (checklist-accuracy finding of the identity-auth slice report on #23878 (6216631966)). Both read the enable dialog's 'shown only once' as a promise about the secret, but the copy says 'The backup codes are shown only once' — so a password-gated get-totp-uri that re-reveals the same secret contradicts no copy. The clause records the measured behaviour and leaves the design question (is a re-revealable secret acceptable?) to a ruling. Wording only: the step's request and the oracle are unchanged", + "ref": "#24036" } ] }, @@ -1386,7 +1407,7 @@ "title": "Enrollment is inert until one correct TOTP verifies on the session lane — and the run records what sys_two_factor.verified actually carries in between", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": ["a signed-in user mid-enrollment (session lane)", "the same user at the sign-in challenge (cookie lane)"], @@ -1405,7 +1426,7 @@ "arm, boot, run the assert-armed guard (features.twoFactor true, /two-factor/enable non-404) — nothing below is scored without it", "sign in, POST /two-factor/enable { password }, decode the secret from the totpURI (the copied helper)", "IMMEDIATELY read the sys_two_factor row via the system-context ObjectQL read and record the literal value of `verified` — this is the latent-default probe (see the clause), captured before anything else touches the row", - "before any verification, drive the cookie lane: POST /auth/sign-in/email with the password, record whether twoFactorRedirect interposes, then attempt POST /two-factor/verify-totp through the two-factor cookie with a CORRECT code from the new secret — better-auth's documented posture is that the sign-in path refuses an unverified enrolment (TOTP_NOT_ENABLED) before any lockout bookkeeping (the lockout pin's own comment, two-factor-lockout.dogfood.test.ts); record the actual status/code", + "before any verification, drive the password stage: POST /auth/sign-in/email with the password and record the answer. An unverified enrolment does not interpose a challenge — the sign-in answers a session with no twoFactorRedirect and no two-factor cookie — so there is no cookie lane on which the new factor could be presented: better-auth's TOTP_NOT_ENABLED branch (the sign-in path's refusal of an unverified enrolment, the lockout pin's own comment, two-factor-lockout.dogfood.test.ts) is unreachable over HTTP and is cited from that pin, not driven. Record whether twoFactorRedirect appeared", "complete enrollment on the SESSION lane: with the live session (no two-factor cookie), POST /two-factor/verify-totp { code } — this call is isSignIn:false, deliberately touches no lockout counter (test), and 200s (the setup precedent, until now cited by no checklist item)", "re-read the row: verified must now be true; sys_user.two_factor_enabled true", "prove activation on the cookie lane: a fresh password sign-in stops at the challenge (twoFactorRedirect: true + two-factor cookie, test) and a correct code through that cookie completes into a session — NOTE completing enrollment/sign-in rotates the session token (test), so refresh your bearer before any follow-up calls or a stale-token 401 will read like a defect", @@ -1419,10 +1440,10 @@ "evidence": "the config read + probe status" }, { - "clause": "a fresh enrollment is INERT at sign-in: between enable and the first successful verify, the new factor cannot complete a sign-in — the cookie-lane verify-totp with a correct code from the just-enabled secret is refused (better-auth's posture: an unverified enrolment draws TOTP_NOT_ENABLED before any lockout bookkeeping). The run records the exact status and error code observed, including whether the password stage even interposed a challenge for the unverified enrollment", + "clause": "a fresh enrollment is INERT at sign-in: between enable and the first successful verify, the new factor takes no part in a sign-in — the password sign-in neither interposes a challenge (no twoFactorRedirect, no two-factor cookie) nor asks for the factor, so a not-yet-verified secret can complete nothing. The in-process refusal of an unverified enrolment (TOTP_NOT_ENABLED, before any lockout bookkeeping) is unreachable over HTTP, because no cookie lane exists to carry the code; it is cited from the lockout pin", "oracle": "api", - "verify": "the pre-verification cookie-lane attempt with a CORRECT code returns non-2xx; record status + body error code and whether sign-in answered twoFactorRedirect at that point. The expectation's source is the lockout pin's setup comment (two-factor-lockout.dogfood.test.ts) — if the live behavior diverges, record what actually happened rather than forcing the wording", - "evidence": "the sign-in + refused-verify trace" + "verify": "the pre-verification password sign-in answers a session with NO twoFactorRedirect and sets no two-factor cookie; sys_user.two_factor_enabled is still false. A twoFactorRedirect at this point (the unverified factor challenged) or a session that requires the new code is the divergence to record. The TOTP_NOT_ENABLED branch is cited from two-factor-lockout.dogfood.test.ts, not driven", + "evidence": "the pre-verification sign-in response (no twoFactorRedirect) + the two_factor_enabled read" }, { "clause": "LATENT-DEFAULT PROBE (observe-and-flag, auth-integrity — NOT a confirmed defect): record which value sys_two_factor.verified actually carries immediately post-enable. The suspicion it encodes: the object declares verified with defaultValue: true (sys-two-factor.object.ts) while better-auth enrolls verified:false, and AUTH_TWO_FACTOR_SCHEMA maps only the four RENAMED fields (backupCodes/userId/failedVerificationCount/lockedUntil — auth-schema-config.ts; `verified` needs no rename, so its absence from the map is not itself the defect). IF better-auth ever omits the column on insert, the ObjectQL default would mark an unverified enrollment active. The clause is satisfied by the accurate record; verified===false post-enable is the healthy reading", @@ -1457,7 +1478,8 @@ "packages/plugins/plugin-auth/src/auth-manager.ts (arming default false, resolved once at construction)" ], "history": [ - { "revision": 1, "date": "2026-08-20", "change": "authored in the scoped scan-functionality (扫描功能) coverage sweep: the enable→verify→active transition existed only as uncited test SETUP in the lockout pin. Encodes the inert-until-verified contract, the session-lane/cookie-lane split, and the verified-defaultValue:true latent-default probe (observe-and-flag, worded as suspicion not fact)", "ref": "claude/new-session-0pv25p" } + { "revision": 1, "date": "2026-08-20", "change": "authored in the scoped scan-functionality (扫描功能) coverage sweep: the enable→verify→active transition existed only as uncited test SETUP in the lockout pin. Encodes the inert-until-verified contract, the session-lane/cookie-lane split, and the verified-defaultValue:true latent-default probe (observe-and-flag, worded as suspicion not fact)", "ref": "claude/new-session-0pv25p" }, + { "revision": 2, "date": "2026-10-17", "change": "steps[3] and acceptance[1] (clause, verify, evidence) re-pointed (checklist-accuracy finding of the identity-auth slice report on #23878 (6216631966)). The cookie-lane verify-totp the step asked for cannot be driven: an unverified enrolment interposes no challenge at the password stage, so there is no two-factor cookie and the TOTP_NOT_ENABLED branch is unreachable over HTTP. Inertness is now read where it shows — the password sign-in answers without twoFactorRedirect and two_factor_enabled stays false — and the in-process refusal is cited from the lockout pin. Not loosened: an unverified factor that takes part in a sign-in still FAILS", "ref": "#24036" } ] }, { @@ -1465,7 +1487,7 @@ "title": "Backup codes: revealed once at enable, spendable exactly once at the sign-in challenge, and regeneration kills the prior set — and the navigable regenerate surface reveals the new set in a one-shot dialog", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": ["a user with a VERIFIED enrollment (run identity-auth.two-factor-verify-to-activate's path first)", "the same user at the sign-in challenge spending a code"], @@ -1489,7 +1511,7 @@ "begin a fresh challenge and attempt an UNSPENT code from the OLD set; capture the refusal — regeneration invalidated the prior set wholesale", "spend one NEW code to prove the new set is live", "storage-shape observation: read sys_two_factor.backup_codes via the system-context read and record what the column actually carries (the declaration says JSON-serialized — sys-two-factor.object.ts; record whether the stored form is plaintext codes or an encrypted/hashed blob, without publishing either)", - "read the navigable surface's declaration: sys_user.generate_backup_codes (sys-user.object.ts) declares a resultDialog — backupCodes as code-list + acknowledge, no successMessage — so on the surface a user can reach, regenerating kills the old set AND reveals the new one once; resolve its backupCodes path against the step-5 regenerate response (the sys_two_factor.regenerate_backup_codes variant carries the same reveal and is mounted nowhere)", + "read the navigable surface's declaration: sys_user.generate_backup_codes (sys-user.object.ts) declares a resultDialog — backupCodes as code-list + acknowledge, no successMessage — so on the surface a user can reach, regenerating kills the old set AND reveals the new one once; resolve its backupCodes path against the steps[4] regenerate response (the sys_two_factor.regenerate_backup_codes variant carries the same reveal and is mounted nowhere)", "negative: generate-backup-codes with a wrong password refused (old set must survive the refused attempt); anonymous call 401" ], "acceptance": [ @@ -1526,7 +1548,7 @@ { "clause": "the navigable regenerate reveals the new set: sys_user.generate_backup_codes declares a one-shot resultDialog (backupCodes as code-list + acknowledge; no successMessage, refreshAfter:false) and its backupCodes path resolves to the non-empty new set in the live regenerate response — the reveal that keeps regeneration from being a lockout path on the surface a user can reach", "oracle": "api", - "verify": "the meta read of sys_user.generate_backup_codes shows resultDialog.fields [{path:'backupCodes', format:'code-list'}] and no successMessage; response.backupCodes of the step-5 regenerate is a non-empty string[] (values redacted). Pixels are objectui's ActionResultDialog and are scored only on the browser lane", + "verify": "the meta read of sys_user.generate_backup_codes shows resultDialog.fields [{path:'backupCodes', format:'code-list'}] and no successMessage; response.backupCodes of the steps[4] regenerate is a non-empty string[] (values redacted). Pixels are objectui's ActionResultDialog and are scored only on the browser lane", "evidence": "the sys_user action-metadata read + the path resolution against the regenerate response (codes redacted)" } ], @@ -1550,6 +1572,12 @@ "date": "2026-10-04", "change": "A6, step 9, title and the browser-lane knownGap re-pointed: sys_user.generate_backup_codes now declares a one-shot resultDialog (backupCodes code-list + acknowledge, no successMessage — sys-user.object.ts generate_backup_codes), so the navigable regenerate is no longer a lockout path; A6 asserts the declaration and resolves its path against the live regenerate response instead of flagging the toast-only gap. Stale clause. Source 266654d82e (#11110)", "ref": "#21735" + }, + { + "revision": 3, + "date": "2026-10-17", + "change": "steps[8] and acceptance[5].verify cite the regenerate by its index (checklist-accuracy finding of the identity-auth slice report on #23878 (6216631966)): they said 'step-5 regenerate', but the regenerate is steps[4]. Wording only", + "ref": "#24036" } ] }, @@ -1645,7 +1673,7 @@ "title": "Zero-user first-run: the /setup owner wizard serves even under OS_DISABLE_SIGNUP, creates the owner + names the personal org through a full-document exit, and every bootstrap bypass closes the moment an owner exists", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "mixed", "personas": ["anonymous visitor on a zero-user boot (the would-be owner)", "the created owner (post-bootstrap)", "a second anonymous visitor (post-bootstrap forger)"], @@ -1669,7 +1697,7 @@ "capture the network: the wizard drives POST /api/v1/auth/sign-up/email and it SUCCEEDS despite OS_DISABLE_SIGNUP=true — the before-hook flips disableSignUp off for exactly this request when isBootstrapCreation() (auth-manager.ts; comment), and the audience gate's isBootstrap arm admits it under the default invite_only posture (audience-posture.ts)", "capture the exit: a FULL-DOCUMENT navigation (window.location.assign), never a SPA route change — the landing console renders the owner's world, not the anonymous-era empty app list (the objectui#4181 hazard, SetupPage.tsx)", "verify server state: GET /api/v1/auth/get-session identifies the owner; GET /api/v1/auth/organization/list shows the auto-provisioned org carrying the TYPED display name (the wizard renames the bootstrap org — it must NOT create a second one; single-org mode forbids createOrganization)", - "bypass closed, door 1: as a second anonymous visitor on the same OS_DISABLE_SIGNUP=true boot, POST /api/v1/auth/sign-up/email → server-side refusal (a human user now exists; the toggle is enforced again)", + "bypass closed, door 1: on the same OS_DISABLE_SIGNUP=true boot, now that a human user exists, forge POST /api/v1/auth/sign-up/email from TWO addresses: an UNINVITED anonymous address is refused first by the audience gate, 403 SELF_REGISTRATION_CLOSED (invite_only), so it cannot show the toggle; have the owner invite a second address and forge that one — it passes the audience gate and the re-enforced toggle refuses it, 400 EMAIL_PASSWORD_SIGN_UP_DISABLED. The invited address is the one that proves door 1", "bypass closed, door 2: on the stock contrast boot (owner exists, no OS_DISABLE_SIGNUP), the same forge refuses 403 SELF_REGISTRATION_CLOSED — the default audience posture invite_only closes it (audience-posture.ts); record WHICH door refused on each boot", "wizard never re-renders: GET bootstrap-status → {hasOwner:true}; anonymous /setup redirects through the login contract (/login?redirect=%2Fsetup); the signed-in owner's /setup resolves to the platform-administration deep link (the route's SECOND meaning, objectui#2794 — not a RouteNotFound, not the wizard)" ], @@ -1701,8 +1729,8 @@ { "clause": "the bypass CLOSES on both doors the moment an owner exists: a post-bootstrap anonymous sign-up is refused server-side under OS_DISABLE_SIGNUP=true (toggle re-enforced), and on a stock boot it is refused 403 SELF_REGISTRATION_CLOSED (default invite_only posture) — there is never a second self-minted 'owner'", "oracle": "api", - "verify": "the two forged sign-ups, one per boot, each non-2xx with the door named; no new sys_user row after either", - "evidence": "the two refusals + the unchanged user reads" + "verify": "door 1 (the OS_DISABLE_SIGNUP boot): the INVITED address's forged sign-up answers 400 EMAIL_PASSWORD_SIGN_UP_DISABLED — the uninvited address's 403 SELF_REGISTRATION_CLOSED there is the audience gate and is recorded, not scored as door 1; door 2 (the stock contrast boot): the uninvited forge answers 403 SELF_REGISTRATION_CLOSED. Each refusal non-2xx with its address and door named; no new sys_user row after any of them", + "evidence": "the refusals with address (invited / uninvited), boot and code + the unchanged user reads" }, { "clause": "/setup keeps its two meanings honestly: with hasOwner:true the wizard never renders — anonymous visitors get the /login?redirect=%2Fsetup contract, and the signed-in owner lands on the platform-administration deep link", @@ -1729,7 +1757,8 @@ "packages/cli/src/commands/dev.ts,339 (seed-admin defaults ON — why a zero-user boot needs --no-seed-admin)" ], "history": [ - { "revision": 1, "date": "2026-08-30", "change": "authored in the 2026-08-30 coverage sweep ([x2] hit, angles 1+4): the zero-user first-run wizard had no item. Encodes the public bootstrap-status door, the deliberate disableSignUp + audience-gate bypass scoped to the zero-user state, the full-document exit hazard (objectui#4181), the rename-not-create org contract, both closed-door contrasts, and /setup's two meanings (objectui#2794); recorded the rows-vs-humans divergence between the two bootstrap probes as observe-and-record", "ref": "#sweep-2026-08-30" } + { "revision": 1, "date": "2026-08-30", "change": "authored in the 2026-08-30 coverage sweep ([x2] hit, angles 1+4): the zero-user first-run wizard had no item. Encodes the public bootstrap-status door, the deliberate disableSignUp + audience-gate bypass scoped to the zero-user state, the full-document exit hazard (objectui#4181), the rename-not-create org contract, both closed-door contrasts, and /setup's two meanings (objectui#2794); recorded the rows-vs-humans divergence between the two bootstrap probes as observe-and-record", "ref": "#sweep-2026-08-30" }, + { "revision": 2, "date": "2026-10-17", "change": "steps[7] and acceptance[4] (verify, evidence) corrected (checklist-accuracy finding of the identity-auth slice report on #23878 (6216631966)). On the OS_DISABLE_SIGNUP boot an uninvited forge is refused first by the audience gate (403 SELF_REGISTRATION_CLOSED, invite_only), so door 1 — the re-enforced toggle — was never reached by the step's anonymous visitor; only an invited address reaches it, answering 400 EMAIL_PASSWORD_SIGN_UP_DISABLED. The step adds the owner's invitation and the clause names which address proves which door. Not loosened: every forged sign-up after the owner exists must still be refused with no row written", "ref": "#24036" } ] }, { @@ -1737,7 +1766,7 @@ "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": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": ["anonymous uninvited registrant", "an invited email's registrant", "org admin (mints the invitation)", "env owner (sets OS_DISABLE_SIGNUP)"], @@ -1749,7 +1778,7 @@ "a second boot with OS_DISABLE_SIGNUP=true exported for the disabled legs" ], "knownGaps": [ - "the email_domain and open posture variants need a DECLARED audience, and no stock example declares one. Two doors declare it: the stack config (AuthConfigSchema.audience, packages/spec/src/system/auth-config.zod.ts), or the auth settings namespace — the console's Audience group, or its env overrides OS_AUTH_AUDIENCE_POSTURE, OS_AUTH_AUDIENCE_ALLOWED_EMAIL_DOMAINS and OS_AUTH_AUDIENCE_SELF_REGISTRATION_PERMISSION_SET (the settings service derives an OS_AUTH_* override for every auth manifest key; bindAuthSettings in auth-plugin.ts applies them). Either door must declare selfRegistrationPermissionSet (entry validation refuses a permitting posture without it, audience-posture.ts). The env door is measured in its two halves, not yet on a live stock boot: the settings service resolves those variables as source env, locked, and audience-posture-setting.test.ts pins bindAuthSettings applying env-sourced audience keys. Until a run records a declared audience, those variants are blocked(fixture) on the stock boot", + "the email_domain and open posture variants need a DECLARED audience, and no stock example declares one. Two doors declare it: the stack config (AuthConfigSchema.audience, packages/spec/src/system/auth-config.zod.ts), or the auth settings namespace — the console's Audience group, or its env overrides OS_AUTH_AUDIENCE_POSTURE, OS_AUTH_AUDIENCE_ALLOWED_EMAIL_DOMAINS and OS_AUTH_AUDIENCE_SELF_REGISTRATION_PERMISSION_SET (the settings service derives an OS_AUTH_* override for every auth manifest key; bindAuthSettings in auth-plugin.ts applies them). Either door must declare selfRegistrationPermissionSet (entry validation refuses a permitting posture without it, audience-posture.ts). The env door is measured LIVE for the open posture (the identity-auth slice report on #23878 (6216631966)): a stock boot with OS_AUTH_AUDIENCE_POSTURE=open and OS_AUTH_AUDIENCE_SELF_REGISTRATION_PERMISSION_SET exported admits an uninvited sign-up (the settings service resolves both as source env, locked; audience-posture-setting.test.ts pins bindAuthSettings applying them). The open variant is therefore runnable on stock through that door; the email_domain variant rides the same door with OS_AUTH_AUDIENCE_ALLOWED_EMAIL_DOMAINS", "BOUNDARY: email_domain always forces requireEmailVerification on, and open forces it on unless the deployment declares it off (OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false, or emailAndPassword.requireEmailVerification: false in the stack config; a false stored only through the settings console is refused under open — resolveEmailVerificationRequirement and assertAudienceConfig, audience-posture.ts). On a verification-forced boot the completed-signup → challenge → verify loop belongs to identity-auth.email-verification-loop — this item owns the ADMISSION decision only; do not double-cover", "the zero-user bootstrap bypass is the ONE admission exception and is owned by identity-auth.first-run-owner-bootstrap — cross-reference, do not re-test here" ] @@ -1760,7 +1789,7 @@ "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)", - "fire POST /api/v1/auth/sign-up/email directly on the disabled boot anyway and capture the server-side refusal — env wins over config/settings (readDisableSignUpEnv: OS_AUTH_SIGNUP_ENABLED inverts and wins, then OS_DISABLE_SIGNUP — auth-manager.ts; wired, ssoOnly forces it true)", + "fire POST /api/v1/auth/sign-up/email directly on the disabled boot anyway and capture the server-side refusal, from TWO addresses, because two gates answer and the first one wins: an UNINVITED address is refused first by the audience gate, 403 SELF_REGISTRATION_CLOSED (invite_only), before the toggle is consulted; an INVITED address (an org admin's pending invitation for it) passes the audience gate and reaches the toggle, 400 EMAIL_PASSWORD_SIGN_UP_DISABLED. Only the invited address proves OS_DISABLE_SIGNUP — env wins over config/settings (readDisableSignUpEnv: OS_AUTH_SIGNUP_ENABLED inverts and wins, then OS_DISABLE_SIGNUP — auth-manager.ts; wired, ssoOnly forces it true)", "record the posture × outcome table for whichever variants this run's boots covered" ], "acceptance": [ @@ -1791,8 +1820,8 @@ { "clause": "OS_DISABLE_SIGNUP's real gate: the direct sign-up endpoint refuses server-side on the disabled boot — the UI bounce is defense-in-depth, the server is the authority (ADR-0124 D1/D5)", "oracle": "api", - "verify": "the direct POST /api/v1/auth/sign-up/email on the disabled boot returns non-2xx and creates no row", - "evidence": "the forged-request refusal + the row-absence read" + "verify": "on the disabled boot, the INVITED address's direct POST /api/v1/auth/sign-up/email answers 400 EMAIL_PASSWORD_SIGN_UP_DISABLED and creates no row — that is the toggle's refusal; the uninvited address's 403 SELF_REGISTRATION_CLOSED is the audience gate's and proves nothing about the toggle. Record which address drew which code; an invited sign-up that succeeds is the FAIL", + "evidence": "the two forged-request refusals, each with its address (invited / uninvited) and code, + the row-absence read" } ], "negative": [ @@ -1822,7 +1851,8 @@ "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": 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" } + { "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" }, + { "revision": 4, "date": "2026-10-17", "change": "knownGaps[0], steps[5] and acceptance[4] (verify, evidence) corrected (checklist-accuracy findings of the identity-auth slice report on #23878 (6216631966)). On an OS_DISABLE_SIGNUP boot an uninvited forge never reaches the toggle: the audience gate refuses it first, 403 SELF_REGISTRATION_CLOSED, so 'returns non-2xx' passed on the wrong gate; only an invited address reaches the toggle and draws 400 EMAIL_PASSWORD_SIGN_UP_DISABLED. The step and clause now say which address proves which door. knownGaps[0] was stale: the open posture through the env door is measured live, so that variant is runnable on stock. Not loosened: the toggle must still refuse the invited address with no row written", "ref": "#24036" } ] }, { @@ -1830,7 +1860,7 @@ "title": "Email verification loop: a challenged sign-up lands a captured verification mail, unverified sign-in is refused EMAIL_NOT_VERIFIED server-side, the link admits, resend works — and the default boot never challenges", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "mixed", "personas": ["a fresh registrant (unverified, then verified)", "admin (reads state)", "env owner (configures the verification boot)"], @@ -1839,18 +1869,18 @@ "requires": [ "a VERIFICATION-ENABLED boot — no stock boot challenges. Two ways to arm it: (a) config emailAndPassword.requireEmailVerification:true + emailVerification.sendOnSignUp:true (wired at auth-manager.ts), or (b) a self-registration posture that FORCES requireEmailVerification on — email_domain always, open unless the deployment declared it off (OS_AUTH_REQUIRE_EMAIL_VERIFICATION=false, or emailAndPassword.requireEmailVerification: false in the stack config). resolveEmailVerificationRequirement (audience-posture.ts) is the one rule the wiring and the /auth/config mirror both read; assertAudienceConfig refuses an explicit false under email_domain from any source, and under open when only the settings console stored it", "an ADMISSIBLE sign-up to feed the loop: on an (a)-style boot the default posture is still invite_only, so either mint a pending invitation for the test address first, or use a (b)-style open-posture boot (which must declare selfRegistrationPermissionSet)", - "the dev `log` mail transport so the verification mail (template auth.verify_email) and its link are CAPTURED, not sent — same harness as identity-auth.self-service-password-reset" + "the verification mail (template auth.verify_email) and its link CAPTURED, not sent — a stock boot sends through the dev `log` transport; the mail is read from its sys_email row (admin GET /api/v1/data/sys_email — body_text / body_html hold the rendered body and its link): the dev `log` transport is not an env switch (EMAIL_TRANSPORT_PROVIDERS is a const list of what the package can build, packages/plugins/plugin-email/src/transports/index.ts) and LogTransport logs only to, subject and hasText/hasHtml — never the body or the link (packages/plugins/plugin-email/src/email-service.ts) — same harness as identity-auth.self-service-password-reset" ], "knownGaps": [ "no stock boot enables verification — the enabled legs need the bespoke config boot above and are blocked(fixture) without it; the default-boot never-challenges leg runs on stock fixtures", - "capturing the verification TOKEN requires the log transport; a real provider with no capture hook blocks the link-admits and resend clauses only" + "capturing the verification TOKEN means reading the mail's sys_email row (admin GET /api/v1/data/sys_email); a provider that persists no sys_email row blocks the link-admits and resend clauses only" ] }, "steps": [ - "on the verification boot, sign up a fresh admissible address: the console routes to /verify-email-prompt after sign-up (RegisterPage header contract) and the verification mail lands at the log transport via template auth.verify_email — capture the link/token (sendVerificationEmail, auth-manager.ts: template failures THROW into the log rather than silently dropping)", + "on the verification boot, sign up a fresh admissible address: the console routes to /verify-email-prompt after sign-up (RegisterPage header contract) and the verification mail is sent via template auth.verify_email — capture the link/token from its sys_email row (admin GET /api/v1/data/sys_email; the LogTransport line carries no link) (sendVerificationEmail, auth-manager.ts: template failures THROW into the log rather than silently dropping)", "attempt POST /api/v1/auth/sign-in/email for the unverified user: capture the refusal carrying EMAIL_NOT_VERIFIED, and in the browser confirm the login page redirects that error into /verify-email-prompt (LoginPage.tsx,346,360)", - "resend from the prompt page (useAuth().sendVerificationEmail → POST /api/v1/auth/send-verification-email, ledgered auth-route-ledger.ts) and capture the second mail at the transport", - "follow the captured link: the console's /verify-email consumes ?token= via GET /api/v1/auth/verify-email?token=… — better-auth serves the route as GET ONLY (a POST answers 404, objectui#11633), and sent WITHOUT callbackURL it answers JSON ({status: true, user} once the token verifies, 401 {code, message} for a garbage or expired one) instead of a 302, which is how the SPA keeps the post-verify UX (VerifyEmailPage.tsx; ledger row GET /api/v1/auth/verify-email) — and renders the success state", + "resend from the prompt page (useAuth().sendVerificationEmail → POST /api/v1/auth/send-verification-email, ledgered auth-route-ledger.ts) and capture the second mail from its own, newer sys_email row", + "follow the captured link: the console's /verify-email consumes ?token= via GET /api/v1/auth/verify-email?token=… — better-auth serves the route as GET ONLY (a POST answers 404, objectui#11633), and sent WITHOUT callbackURL it answers JSON ({status: true, user: null} once the token verifies — no user object, 401 {code, message} for a garbage or expired one) instead of a 302, which is how the SPA keeps the post-verify UX (VerifyEmailPage.tsx; ledger row GET /api/v1/auth/verify-email) — and renders the success state", "sign in again with the same credentials: admitted; get-session identifies the user", "feed a garbage/expired token to /verify-email: the page renders its designed error state (named message, retry path), and the user's verification state is unchanged (sign-in still refused)", "default-boot contrast (stock showcase, no verification config): an admitted creation (seeded persona or invited registrant) signs in immediately with NO challenge — and /auth/config advertises emailPassword.requireEmailVerification false", @@ -1866,11 +1896,11 @@ { "clause": "sendOnSignUp produces a real mail artifact: the sign-up lands an auth.verify_email templated mail at the log transport containing a working verification link", "oracle": "log", - "verify": "the transport output for the sign-up carries the template render with a tokened link (auth-manager.ts)", + "verify": "the sys_email row the sign-up wrote (to = the address, the auth.verify_email render) carries a tokened verification link in its body (auth-manager.ts)", "evidence": "the captured transport output" }, { - "clause": "the captured token verifies and flips admission: GET /api/v1/auth/verify-email?token=… (no callbackURL) answers {status: true}, and the previously-refused sign-in now returns a session. There is no POST variant: it answers 404", + "clause": "the captured token verifies and flips admission: GET /api/v1/auth/verify-email?token=… (no callbackURL) answers {status: true, user: null} — the body carries no user object — and the previously-refused sign-in now returns a session. There is no POST variant: it answers 404", "oracle": "api", "verify": "verify-email 2xx → sign-in 2xx → get-session identifies the user", "evidence": "the verify + sign-in + session trace" @@ -1878,7 +1908,7 @@ { "clause": "resend issues a fresh working mail: the prompt page's resend lands a second capture whose link also verifies", "oracle": "log", - "verify": "the second transport capture after POST /send-verification-email; its token is accepted (or the first's already-consumed state is reported loudly — record which)", + "verify": "the second sys_email row after POST /send-verification-email; verify with ITS token first, while the user is still unverified, and record the 200. A token presented after the user is already verified answers a silent 200 {status: true} too — consumed or not, there is no loud already-consumed refusal — so a success after the first verification proves nothing about the token; record that behaviour, do not score it", "evidence": "the second capture + the follow-up verify response" }, { @@ -1915,7 +1945,8 @@ "history": [ { "revision": 1, "date": "2026-08-30", "change": "authored in the 2026-08-30 coverage sweep (angle 4): the verification loop (challenge, capture, verify, resend, default-never-challenges) had no item. Grounded the #11739 posture-forces-verification invariant and its advertised mirror, the console's three surfaces (/verify-email POST-variant consumption, /verify-email-prompt resend, LoginPage's EMAIL_NOT_VERIFIED redirect), and the admissibility precondition the audience wall adds to the fixture", "ref": "#sweep-2026-08-30" }, { "revision": 2, "date": "2026-09-28", "change": "restated the forced-verification wording to the shipped rule: email_domain always forces verification 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); a false stored only through the settings console is refused under open. The (b) fixture, clause 6, the mirror negative and two source notes said every permitting posture forces it; the negative now names the open opt-out boot, which advertises false correctly, as not a FAIL. Steps, oracles and the other clauses are unchanged", "ref": "#20413" }, - { "revision": 3, "date": "2026-10-13", "change": "steps[3], acceptance[2].clause and source[4] name the GET verify-email the console uses (stale step, #23166 C2; parked on #23121, carried by #23169). POST /api/v1/auth/verify-email answers 404 — better-auth serves the route as GET only — and the console's VerifyEmailPage calls GET ?token= without a callbackURL, which answers JSON (objectui#11633, at 1fb1ced36243, still this repo's .objectui-sha). The route ledger already books only the GET row. QA run #23166 verified through GET and passed", "ref": "#23169" } + { "revision": 3, "date": "2026-10-13", "change": "steps[3], acceptance[2].clause and source[4] name the GET verify-email the console uses (stale step, #23166 C2; parked on #23121, carried by #23169). POST /api/v1/auth/verify-email answers 404 — better-auth serves the route as GET only — and the console's VerifyEmailPage calls GET ?token= without a callbackURL, which answers JSON (objectui#11633, at 1fb1ced36243, still this repo's .objectui-sha). The route ledger already books only the GET row. QA run #23166 verified through GET and passed", "ref": "#23169" }, + { "revision": 4, "date": "2026-10-17", "change": "fixtures.requires[2], knownGaps[1], steps[0], [2] and [3] and acceptance[1].verify, [2].clause and [3].verify re-pointed (checklist-accuracy findings of the identity-auth slice report on #23878 (6216631966)). Three premises had moved: the dev transport logs no body or link, so the mail is captured from its sys_email row; verify-email answers {status: true, user: null}, so the step and clause no longer imply a user object; and a consumed token presented for an already-verified user answers a silent 200, so clause 3's 'or the first's already-consumed state is reported loudly' described a refusal that never happens — the resend is now proven by verifying with the resent token first. Not loosened: the resent mail must still carry a token that verifies", "ref": "#24036" } ] }, { @@ -1923,7 +1954,7 @@ "title": "Workspace switcher: org chrome follows posture × membership count (nothing under single; read-only indicator at one membership; switcher at two), and a switch is a full-document reload after which every tenant-scoped read answers from the new org", "since": "v17", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "mixed", "personas": ["a member with one org membership", "the same member after gaining a second membership", "admin (provisions the second org)"], @@ -1944,7 +1975,7 @@ "provision the second org + membership; reload: the switcher renders (data-testid workspace-switcher) and the indicator yields (it renders ONLY at exactly one membership, CurrentOrganizationIndicator.tsx)", "under `group` posture, open the dropdown and capture the labeling: 'Working organization' + the writes-here/reads-span-all hint (data-testid workspace-switcher-group-hint, WorkspaceSwitcher.tsx); under `isolated` it reads 'Switch organization'", "switch to the second org: the client calls switchOrganization (POST /api/v1/auth/organization/set-active, auth-route-ledger.ts) and then performs a FULL-DOCUMENT navigation to the console root (window.location.href = resolveRootUrl(), WorkspaceSwitcher.tsx) — capture that it is a document reload, not a SPA transition", - "after the reload: GET /api/v1/auth/organization/get-active-member confirms the new active org; re-drive one tenant-scoped data read and confirm it answers from the NEW org — pick one ROW id that exists only in the OLD org and prove its absence. Re-drive one /meta read too: it must answer the SAME as before the switch. Since ADR-0131 D6/D7 metadata is deployment-level — no metadata write is organization-scoped (packages/metadata-protocol/src/protocol.ts) — so no org-only metadata id exists to use as a sentinel; what the switch must still do on the meta side is drop the console's metadata cache (objectui#4486, MetadataProvider.tsx) so nothing stale is served", + "after the reload: GET /api/v1/auth/organization/get-active-member confirms the new active org; re-drive one tenant-scoped data read and confirm it answers from the NEW org. Under `isolated`, pick one ROW id that exists only in the OLD org and prove its absence. Under `group` the active org bounds WRITES, not reads (reads span every org the user belongs to — the switcher's own hint), so the old-org row is legitimately still readable: there, create one row after the switch and prove it lands in the NEW org (its organization column), instead of an absence check. Re-drive one /meta read too: it must answer the SAME as before the switch. Since ADR-0131 D6/D7 metadata is deployment-level — no metadata write is organization-scoped (packages/metadata-protocol/src/protocol.ts) — so no org-only metadata id exists to use as a sentinel; what the switch must still do on the meta side is drop the console's metadata cache (objectui#4486, MetadataProvider.tsx) so nothing stale is served", "failure leg: as the member, forge POST /api/v1/auth/organization/set-active with an org id they hold NO membership in; capture the server refusal (403 USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION) and read get-active-member: the foreign org is never active. better-auth clears the session's active org on this refusal, so 'none' is an expected reading, not a fault. Confirm the UI never renders the foreign org as active" ], "acceptance": [ @@ -1967,9 +1998,9 @@ "evidence": "the navigation trace" }, { - "clause": "tenant-scoped reads follow the switch: after the reload, data reads answer from the new org — a row id that exists only in the previous org is ABSENT — and /meta reads answer exactly as before the switch, because metadata is deployment-level since ADR-0131 D6/D7 (no organization-scoped metadata write exists, so there is no org-only metadata id to be absent); the console drops its metadata cache on the change (objectui#4486)", + "clause": "tenant-scoped reads follow the switch: after the reload, data reads answer from the new org — under `isolated` a row id that exists only in the previous org is ABSENT; under `group`, where reads span all the user's orgs, a row written after the switch lands in the NEW org — and /meta reads answer exactly as before the switch, because metadata is deployment-level since ADR-0131 D6/D7 (no organization-scoped metadata write exists, so there is no org-only metadata id to be absent); the console drops its metadata cache on the change (objectui#4486)", "oracle": "api", - "verify": "the post-switch data read: the old-org sentinel row id is not in it; the post-switch /meta read equals the pre-switch one. A metadata id that differs between the two orgs would itself be a finding against ADR-0131 D6, not this clause's pass", + "verify": "under `isolated`, the post-switch data read lacks the old-org sentinel row id; under `group`, a post-switch create carries the new org's id and the old-org row is still readable (not a FAIL — group reads span orgs); the post-switch /meta read equals the pre-switch one. A metadata id that differs between the two orgs would itself be a finding against ADR-0131 D6, not this clause's pass", "evidence": "the before/after reads + the sentinel id" }, { @@ -2005,7 +2036,8 @@ "history": [ { "revision": 1, "date": "2026-08-30", "change": "authored in the 2026-08-30 coverage sweep (angle 1): the workspace-switch surface had no item. CORRECTED against source from the register's 'single membership → indicator' hypothesis: the indicator is posture-gated (postureHasOrgWall — group/isolated only), so the stock `single` boot renders NO org chrome at all and the whole matrix needs an OS_TENANCY_POSTURE boot; encoded the three-cell affordance matrix, the deliberate full-document reload, the #4486 cache-drop read-follow proof, group-posture labeling, and the forged-switch refusal", "ref": "#sweep-2026-08-30" }, { "revision": 2, "date": "2026-10-05", "change": "clause 6 and step 8 re-pointed (assertion defect, #21784 VF4). The switch fails closed, and the defect is not of the authorization class. better-auth 1.7.3's setActiveOrganization (plugins/organization/routes/crud-org) calls adapter.setActiveOrganization(token, null) before it throws 403 USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION, and plugin-auth mounts the route unwrapped (auth-route-ledger 'sdk'). So the active org becomes none, never the foreign org, and 'unchanged' was the wrong assertion. The verifier's wording is used. The verifier's optional follow-up, wrapping the vendor call to keep the previous org, would be a product enhancement, not this item's concern", "ref": "#21797" }, - { "revision": 3, "date": "2026-10-13", "change": "fixtures.requires[0] and [1] name the walled-posture boot prerequisites (fixture gap, #23166), and steps[6] and acceptance[3] re-ground the meta half (stale spec, #23166 C3b); both parked on #23121, carried by #23169. QA run #23166 found a walled posture refuses to boot the stock showcase without @objectstack/organizations in the host package.json (ADR-0093 D5), OS_PLATFORM_OWNER_EMAIL and OS_AUTH_MEMBERSHIP_POLICY, and that under invite-only the invitee must accept the invitation. It skipped C3b because its premise is retired: metadata writes are deployment-level (ADR-0131 D6/D7), so no metadata id exists in one org only. The clause keeps the data sentinel and now expects the /meta answer to be identical across the switch", "ref": "#23169" } + { "revision": 3, "date": "2026-10-13", "change": "fixtures.requires[0] and [1] name the walled-posture boot prerequisites (fixture gap, #23166), and steps[6] and acceptance[3] re-ground the meta half (stale spec, #23166 C3b); both parked on #23121, carried by #23169. QA run #23166 found a walled posture refuses to boot the stock showcase without @objectstack/organizations in the host package.json (ADR-0093 D5), OS_PLATFORM_OWNER_EMAIL and OS_AUTH_MEMBERSHIP_POLICY, and that under invite-only the invitee must accept the invitation. It skipped C3b because its premise is retired: metadata writes are deployment-level (ADR-0131 D6/D7), so no metadata id exists in one org only. The clause keeps the data sentinel and now expects the /meta answer to be identical across the switch", "ref": "#23169" }, + { "revision": 4, "date": "2026-10-17", "change": "steps[6] and acceptance[3] (clause, verify) split by posture (checklist-accuracy finding of the identity-auth slice report on #23878 (6216631966)). The sentinel-absent check holds only under `isolated`: under `group` the active org bounds writes, not reads, so a row that exists only in the old org stays readable after the switch, and the check would FAIL a correct product. Under group the clause now proves the switch on the write side — a post-switch create lands in the new org. Not loosened: under isolated the old-org row must still be absent", "ref": "#24036" } ] }, { @@ -2013,7 +2045,7 @@ "title": "Implicit account linking on external sign-in: no implicit link to an unverified local user; after an unlink, implicit sign-in does not re-link; an explicit, signed-in link still works and lifts the refusal; the platform identity provider's exception holds only on its OAuth path", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": [ @@ -2028,7 +2060,7 @@ "requires": [ "the local OIDC provider runtime fixture that identity-auth.linked-accounts-social carries in its fixtures.requires (proved by run #21845): a localhost authorization-code + PKCE OpenID provider, registered through the auth manager's applyConfigPatch from a scratch app plugin before the first auth request. ⛔ Never committed, never a product change", "that provider's test identity answers with an email EQUAL to U's and email_verified: true. A provider that answers email_verified false is refused by the library's own provider-side check first, and the run then measures that gate, not this one", - "a way to verify U's email through the product: the captured-mail verification loop identity-auth.email-verification-loop drives. ⛔ Never by writing sys_user directly" + "a way to verify U's email through the product: the captured-mail verification loop identity-auth.email-verification-loop drives (POST /api/v1/auth/send-verification-email as U, then the link from that mail's sys_email row — admin GET /api/v1/data/sys_email; the dev log transport logs no body or link). ⛔ Never by writing sys_user directly" ], "knownGaps": [ "the platform identity provider's exception (acceptance[4]) is scored from the unit pin. A live run would need the fixture provider registered under the id objectstack-cloud on the OAuth method, and no run has proved that registration yet", @@ -2117,6 +2149,12 @@ "date": "2026-10-13", "change": "steps[6] says W's provider identity is a distinct sub (fixture note, #23166 C3; parked on #23121, carried by #23169). The step read 'the same provider identity re-pointed to W's email', but the local OIDC fixture derives `sub` from the email, so W's identity is a different provider account, and W's explicit link writes W's own sys_account row. QA run #23166 drove it that way and passed acceptance[3]. No clause changed", "ref": "#23169" + }, + { + "revision": 3, + "date": "2026-10-17", + "change": "fixtures.requires[2] names where the verification mail is read (checklist-accuracy finding of the identity-auth slice report on #23878 (6216631966)): the dev log transport logs no body or link, so the link comes from the mail's sys_email row. Wording only: no step, request or oracle changed", + "ref": "#24036" } ] }, diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index 1a3083a281..a05b3c99f7 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -428,7 +428,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": 4, + "revision": 5, "priority": "P2", "surface": "browser", "personas": [ @@ -447,7 +447,7 @@ "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, or a scratch flow authored env-wide through POST /api/v1/automation, which opens editable — knownGaps) 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)", - "open the connector picker; screenshot; only AFTER the screenshot, read the DOM list", + "focus the connector field and screenshot it, which confirms the field rendered; the picker is a NATIVE input with a datalist, whose open list a headless screenshot cannot show, so read the datalist's options from the DOM only AFTER that screenshot", "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" ], @@ -455,8 +455,8 @@ { "clause": "the picker lists exactly the instances the API reports — no extras (a picker inventing catalog descriptors would let authors wire undispatchable nodes), none missing", "oracle": "screenshot", - "verify": "picker screenshot cross-checked name-by-name against the pre-captured listing", - "evidence": "screenshot + API read, diffed" + "verify": "the field screenshot confirms render; the datalist's options, read from the DOM after it, are cross-checked name-by-name against the pre-captured listing (a headless screenshot cannot show a native datalist open, so the list itself is a DOM read)", + "evidence": "the field screenshot + the datalist options read from the DOM, diffed against the API read" }, { "clause": "declarative instances carry their annotation (origin distinguishable from plugin-registered), per the descriptor's origin field the API serves", @@ -508,6 +508,12 @@ "date": "2026-10-13", "change": "knownGaps[0] and steps[1] record that the installed-package lock did not reproduce for an env-wide flow (knownGap stale, #23163 A0; parked on #23121, carried by #23169). QA run #23163 authored the fixture flow through POST /api/v1/automation, and it opened editable with no lock banner; the picker listed exactly the 5 served connectors. The older reading, about flows written over the metadata API, is kept and marked not re-measured", "ref": "#23169" + }, + { + "revision": 5, + "date": "2026-10-16", + "change": "steps[2] and acceptance[0] (verify, evidence) corrected (checklist-accuracy finding of QA run #23922, wave #23878). The connector picker is a native datalist input; a headless screenshot cannot show its open list, so the name-by-name diff #23922 scored was read from the DOM after a screenshot confirmed the field. The step and clause now say so. Not loosened: the listed names must still equal the API's, no extras and none missing", + "ref": "#24036" } ] }, @@ -516,7 +522,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, redeliver terminal rows tenant-scoped via POST /api/v1/webhooks/redeliver, reject retired trigger kinds, and never clobber admin edits", "since": "v15", "status": "active", - "revision": 6, + "revision": 7, "priority": "P1", "surface": "mixed", "fixtures": { @@ -543,7 +549,7 @@ "steps": [ "boot the showcase; verify the materializer bridge: read sys_webhook over /api/v1/data and locate showcase_task_changed with object_name:'showcase_task', active:false, managed_by:'package', and the envelope in definition_json", "in Setup → Integrations → Webhooks flip the shipped showcase_task_changed row active and point its url at the local receiver (an admin edit — it must stamp customized). This edit is the subject of the seed-not-clobber clause (acceptance[6]) ONLY: the row stays org-less, so it delivers nothing on the org-walled showcase (fixtures.requires[0]). Author the in-organization scratch webhook beside it for the delivery legs", - "with the in-organization scratch webhook (fixtures.requires[0]) active and pointed at the receiver, 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)", + "with the in-organization scratch webhook (fixtures.requires[0]) active and pointed at the receiver, 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). The create needs showcase_task's required `project` lookup — pass an existing showcase_project id, or the create answers 400 VALIDATION_FAILED 'Project is required' and no create event fires", "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", @@ -653,6 +659,12 @@ "date": "2026-10-13", "change": "fixtures.requires[0], steps[1] and [2] move the delivery legs onto an in-organization scratch webhook (stale spec, #23166 A1), and knownGaps[1] (new) and acceptance[2].verify record the bulk pair as blocked(dependency) with the maintainer question open (#23166 A2); both parked on #23121, carried by #23169. The shipped showcase_task_changed materializes org-less, and the auto-enqueuer gives an organization-walled event only to its own organization's subscriptions, so on the showcase it never fans out while Setup shows it active. QA run #23166 drove the per-record legs on a webhook authored inside the organization and passed them; in-org bulk flow events carry no organizationId, so no tenant subscription can receive them. N1 / N2 were not attempted and are owed by a re-run; the item's text for them is unchanged", "ref": "#23169" + }, + { + "revision": 7, + "date": "2026-10-16", + "change": "steps[2] names the create body the delivery leg needs (checklist-accuracy finding of QA run #23922, wave #23878): a showcase_task create over REST requires its `project` lookup and answers 400 VALIDATION_FAILED 'Project is required' without one, so the create variant never fires. The clauses are unchanged", + "ref": "#24036" } ], "enumSource": { @@ -666,14 +678,15 @@ "title": "Job metadata schedules through every ScheduleSchema variant, executes with retry/timeout enforced, records sys_job/sys_job_run truth, and refuses the closed doors (id key, runtime create, missing handler)", "since": "v15", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "fixtures": { "app": "showcase", "requires": [ "the shipped cron fixture: showcase_health_sweep (schedule cron '0 1 * * *' UTC, handler 'sweepProjectHealth' registered in defineStack({ functions }), retryPolicy { maxRetries: 2, backoffMs: 5000, backoffMultiplier: 2 }, timeoutMs 300000 — examples/app-showcase/src/automation/jobs/index.ts; the handler-missing regression here was #4774/#4888)", - "scratch jobs in a writable package for the interval / once / failing / timing-out probes (near-term schedules so the run observes real executions)" + "scratch jobs in a writable package for the interval / once / failing / timing-out probes (near-term schedules so the run observes real executions)", + "OS_AUTOMATION_SCHEDULED_WORK_ENABLED=1 exported on every boot of this item: by default a stock boot leaves package-authored scheduled work OFF by deployment policy — the boot banner says so ('… package-authored scheduled work is off on this deployment (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy), so no time trigger arms and no packaged `defineJob` is scheduled'), and at info level AppPlugin logs '[AppPlugin] declarative jobs NOT scheduled — …' (packages/runtime/src/app-artifact-handlers.ts, the reason text in packages/types/src/env.ts#SCHEDULED_WORK_DISABLED_REASON) — so without the switch neither showcase_health_sweep nor any scratch job is scheduled (sys_job holds no showcase_health_sweep row) and every firing clause is unprovable" ] }, "variants": [ @@ -720,9 +733,9 @@ "evidence": "the run row" }, { - "clause": "the two skip doors are LOUD and distinct: enabled:false skips scheduling at registration; a handler naming no registered function skips with '[AppPlugin] job handler not found in bundle.functions — skipping' — the exact silent-no-op that let the showcase sweep never run for months (#4774/#4888)", + "clause": "the two skip doors are distinct, and the dangerous one is LOUD: a handler naming no registered function skips with a WARN, '[AppPlugin] job handler not found in bundle.functions — skipping' — the exact silent no-op that let the showcase sweep never run for months (#4774/#4888); enabled:false — a deliberate author choice — skips scheduling at registration and says so only at DEBUG, '[AppPlugin] job disabled — skipping' (packages/runtime/src/app-artifact-handlers.ts), so it is read on a boot with --log-level debug", "oracle": "log", - "verify": "both skip lines present; neither job has sys_job_run rows", + "verify": "the handler-missing WARN present at the default log level, and the enabled:false DEBUG line present on a --log-level debug boot; neither job has sys_job_run rows. A handler-missing skip that logs below WARN is the FAIL", "evidence": "log excerpts + absence of runs" }, { @@ -765,6 +778,12 @@ "date": "2026-09-09", "change": "key spelling only, no clause moved — the same #14478 rename residue as records-forms.object-hook-lifecycle: `job.timeout` became `timeoutMs` (old spelling kept as a retiredKey tombstone), so the fixture citation, the timing step, the over-limit clause and the job.zod.ts citation all named a key the schema now refuses. What is exercised is unchanged; the execution STATUS is still 'timeout'.", "ref": "#15839" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "fixtures.requires gains the scheduling switch, and acceptance[4] (clause, verify) is corrected (checklist-accuracy findings of QA run #23922, wave #23878). A stock boot never schedules packaged jobs: OS_AUTOMATION_SCHEDULED_WORK_ENABLED is off by default ('declarative jobs NOT scheduled — disabled by deployment policy'), and neither fixtures nor steps named it. acceptance[4] called both skip doors LOUD, but the enabled:false skip logs at DEBUG by design (app-artifact-handlers.ts) — only the handler-missing skip, the #4774/#4888 regression class, is a WARN. The clause keeps that WARN as the loud requirement and reads the debug line on a debug-level boot. Not loosened: a silent handler-missing skip still FAILS", + "ref": "#24036" } ] }, @@ -773,7 +792,7 @@ "title": "Email templates resolve from the registry (no sys_email_template row since ADR-0131 D6/D10), 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": 6, + "revision": 7, "priority": "P2", "surface": "mixed", "fixtures": { @@ -786,7 +805,7 @@ ], "knownGaps": [ "no stock example exercises an email template end-to-end; needs a small fixture flow with a notify/email step", - "refinement of the gap above (2026-08-07 survey): the TEMPLATE fixture now exists (showcase_task_done_email) and the send path is pinned by packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts, but no shipped FLOW invokes sendTemplate — the flow-driven half still needs the fixture flow, or the send must be driven directly via IEmailService.sendTemplate in the run" + "refinement of the gap above: the TEMPLATE fixture exists (showcase_task_done_email) and the send path is pinned by packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts, but no shipped FLOW invokes sendTemplate. The workable door, measured by QA run #23922: a SCRATCH notify flow authored through POST /api/v1/automation (a runtime flow registers on stock showcase), whose notify node names channels ['email'], template 'showcase_task_done_email' and templateData (CEL value envelopes for title / project), fired through its api trigger — the email channel calls sendTemplate per recipient, and each delivery lands in sys_email (admin GET /api/v1/data/sys_email) with the rendered subject and body" ] }, "variants": [ @@ -799,7 +818,7 @@ ], "steps": [ "boot the showcase; GET /api/v1/meta/email_template/showcase_task_done_email and read the served item — subject, bodyHtml, variables, active. The REGISTRY serves it: since ADR-0131 D6/D10 (#22875) a declared template is read by (name, locale) from the registry, and no sys_email_template row backs it — the table holds 0 rows on a stock boot (QA run #23166) and its organization door is closed (packages/plugins/plugin-email/src/email-template-door.ts)", - "drive a send with full data ({ title, project }) through IEmailService.sendTemplate (or the fixture flow once it exists); capture the rendered subject/body at the dev transport", + "drive a send with full data ({ title, project }) through the scratch notify flow of knownGaps[1] (POST /api/v1/automation; channels ['email'], template, templateData; fired through its api trigger); capture the rendered subject/body from the sys_email row it lands (the dev log transport logs no body)", "drive a send MISSING the required `title`; capture the fast failure", "make the template inactive (a Studio save of the item with active:false, or a scratch template authored active:false) and send; capture the TEMPLATE_INACTIVE error", "author the zh-CN twin; send to a zh-CN-locale recipient and to an unmatched-locale recipient; capture which row rendered each", @@ -918,6 +937,12 @@ "date": "2026-10-13", "change": "the title, fixtures.requires[0] and [3] (new), steps[0], [3], [6] and [7], acceptance[0], [2] and [5] and source[2] re-pointed from the sys_email_template row to the registry (stale spec, #23166 A0 / A5 / A6; parked on #23121, carried by #23169). Since #22875 (ADR-0131 D6/D10) declared templates resolve from the registry by (name, locale); the row projector is gone, the table holds 0 rows on a stock boot, and its organization door is closed. `category` and `isSystem` are retired (#22876), so the fixture line no longer names a category. The declared-source half of acceptance[5] needs a writable showcase copy: QA run #23166 scored it partial on the read-only stock app and passed the admin half. The pin email-template-materialization.dogfood.test.ts already asserts the registry graph, no row included", "ref": "#23169" + }, + { + "revision": 7, + "date": "2026-10-16", + "change": "knownGaps[1] and steps[1] name the HTTP-reachable send door (checklist-accuracy finding of QA run #23922, wave #23878). IEmailService.sendTemplate is not something a runner can call, and no shipped flow invokes it; #23922 drove every send through a scratch notify flow authored via POST /api/v1/automation (channels email, template, templateData), whose deliveries land in sys_email with the rendered subject and body. The step now names that door and reads the render off sys_email. Not loosened: the clauses score the same renders", + "ref": "#24036" } ] }, @@ -926,7 +951,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": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": [ @@ -944,7 +969,7 @@ "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", "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, #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", - "acceptance[3]'s 501 half needs a boot WITHOUT MessagingServicePlugin, and no showcase boot is one: the showcase config lists 'messaging' in `requires` (examples/app-showcase/objectstack.config.ts), so the service is always composed. On a showcase run score the 501 half blocked(fixture) (QA run #23166 A3-501), or cite it from a minimal non-showcase boot without the plugin, naming that boot; the 401 half runs on stock" + "acceptance[3]'s 501 half needs a boot WITHOUT MessagingServicePlugin, and no `objectstack dev` boot is one: 'messaging' is on the always-on slate every served boot mounts (packages/spec/src/kernel/platform-capabilities.ts#PLATFORM_ALWAYS_ON_CAPABILITIES), so even an app whose config does not require it (examples/app-todo, measured by #24036's re-run) composes the service and answers 200. Score the 501 half blocked(fixture) (QA run #23166 A3-501) unless a host composes the kernel without the plugin, naming that host; the 401 half runs on stock" ] }, "variants": [ @@ -960,7 +985,7 @@ "unauthenticated GET /api/v1/notifications; capture the 401", "as the assignee, POST /api/v1/notifications/read with { ids: [] }; re-list with ?read=false and confirm it dropped out; then POST /notifications/read/all", "read the backing rows over the data API (sys_inbox_message + sys_notification_receipt keyed (notification_id, user_id, channel:'inbox')) to confirm the surface reflects storage", - "author a scratch flow copy whose notify node names channels ['push']; run it; capture where the message lands (dead-letter), and that NO inbox row was fabricated" + "author a scratch flow copy whose notify node names channels ['push'] (POST /api/v1/automation registers a runtime flow on stock showcase); run it; capture the server's WARN refusal and confirm that NO inbox row and NO sys_notification_delivery row were written — emit refuses a delivery on an unregistered channel up front rather than enqueueing a row the dispatcher could only dead-letter" ], "acceptance": [ { @@ -988,9 +1013,9 @@ "evidence": "the two responses" }, { - "clause": "an unimplemented channel dead-letters instead of faking success: the ['push'] probe produces NO inbox row and the dispatcher records the dead-letter — declared-but-unimplemented channels must stay visible failures (#3197)", + "clause": "an unimplemented channel never fakes delivery: the ['push'] probe produces NO inbox row and NO delivery row — emit refuses the delivery up front and says so in a WARN naming the channel ('[messaging] emit: channel 'push' is not registered; refused N delivery row(s) the dispatcher could only dead-letter …', packages/services/service-messaging/src/messaging-service.ts) — declared-but-unimplemented channels must stay visible failures (#3197)", "oracle": "log", - "verify": "no sys_inbox_message row for the probe run; the dead-letter/log evidence captured", + "verify": "no sys_inbox_message row and no sys_notification_delivery row for the probe run, and the WARN refusal naming channel 'push' in the server log. A delivery row reported success, or an inbox row, is the FAIL", "evidence": "absence check + log excerpt" } ], @@ -1027,6 +1052,12 @@ "date": "2026-10-13", "change": "knownGaps[2] (new) names why acceptance[3]'s 501 half cannot run on a showcase boot (fixture gap, #23166 A3-501; parked on #23121, carried by #23169). The clause already allowed citing it from a minimal boot without the messaging plugin, but nothing said the showcase can never be that boot: its config requires 'messaging'. QA run #23166 scored the half blocked(fixture). No clause changed", "ref": "#23169" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "steps[6] and acceptance[4] (clause, verify) re-pointed (checklist-accuracy finding of QA run #23922, wave #23878). The clause said the dispatcher records a dead-letter for the push probe; on the live pipeline emit refuses the delivery up front — WARN 'channel push is not registered; refused 1 delivery row(s) the dispatcher could only dead-letter' — and no sys_notification_delivery row exists, which is what #23922 read. The clause now scores that refusal: no inbox row, no delivery row, the WARN present. Not loosened: any fabricated delivery or inbox row still FAILS. knownGaps[2] is corrected too: its 'minimal non-showcase boot without the plugin' does not exist, since messaging is on the CLI's always-on slate — app-todo, which does not require it, still answers GET /notifications 200", + "ref": "#24036" } ] }, @@ -1035,7 +1066,7 @@ "title": "A declared external datasource federates: its objects query in place over REST, read-only", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -1058,7 +1089,7 @@ { "clause": "the external datasource's objects appear in /meta and return the fixture rows over the normal /data query path — federation is transparent to the query layer", "oracle": "api", - "verify": "/data/ returns the seeded external rows; filters/$top work as on a native object", + "verify": "/data/ returns the seeded external rows; the JSON `filter` query param (e.g. filter={\"status\":\"paid\"}) and `top` work as on a native object, judged on the returned rows against the unfiltered set. The OData `$filter` spelling is not this route's: it is refused 400 INVALID_FILTER (the filter must be JSON) — a refusal, not a federation failure", "evidence": "the reads" }, { @@ -1092,7 +1123,8 @@ "change": "initial — the coverage.json `datasource` waiver was STALE (showcase ships a read-only SQLite external fixture); un-waived. NOTE: the external-datasource ADMIN CRUD lifecycle is a separate item (datasource-admin-lifecycle)", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-08-18", "change": "re-worded clause 3 and step 5 for this item's declared api surface. Both named the Setup → Datasources console page inside an item whose surface is api, so the clause could only ever be scored by a substitution the item did not authorize. The datasources admin API is what the page renders from, so it is now the oracle and the page is the optional substitution — surface left as api rather than widened to mixed, since every other clause here is a server read (#9417)", "ref": "#9386" } + { "revision": 2, "date": "2026-08-18", "change": "re-worded clause 3 and step 5 for this item's declared api surface. Both named the Setup → Datasources console page inside an item whose surface is api, so the clause could only ever be scored by a substitution the item did not authorize. The datasources admin API is what the page renders from, so it is now the oracle and the page is the optional substitution — surface left as api rather than widened to mixed, since every other clause here is a server read (#9417)", "ref": "#9386" }, + { "revision": 3, "date": "2026-10-16", "change": "acceptance[0].verify corrected (checklist-accuracy finding of QA run #23922, wave #23878): 'filters/$top work as on a native object' — OData $filter is refused 400 INVALID_FILTER on /data (the filter must be JSON), while the JSON filter param and top work, which is what #23922 used. The verify text names the working spellings and the refusal. Not loosened: a filter that returns the unfiltered set still FAILS", "ref": "#24036" } ] }, { @@ -1462,7 +1494,7 @@ "title": "A recipient preference muting the inbox channel for a topic suppresses delivery (no inbox row, no dead-letter fake); flipping it back resumes; a sys_notification_template renders through a delivery", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "mixed", "personas": [ @@ -1488,7 +1520,7 @@ "read the messaging outbox / dead-letter surface and confirm there is no dead-letter row for the muted (recipient, 'inbox') — a deliberate mute is a DROP by the preference filter, not a dead-letter (dead-letter is reserved for unimplemented channels, #3197)", "flip the row enabled:true (or delete it); re-fire; confirm the inbox row is delivered again", "probe the resolution order: a user_id='*' admin-global default OFF with a user-specific row ON delivers (user row overrides global); a mandatoryTopics-configured topic bypasses the matrix entirely (cannot be muted)", - "render a sys_notification_template through a delivery: author a scratch sys_notification_template keyed (topic, channel, locale) and drive a template-backed channel (email/sms — messaging-service-plugin registers them as 'renders sys_notification_template'); capture the rendered subject/body" + "render a sys_notification_template through a delivery: author a scratch sys_notification_template keyed (topic, channel, locale) and drive a template-backed channel (email/sms — messaging-service-plugin registers them as 'renders sys_notification_template'); capture the rendered subject/body. Author the row's locale where the recipient's ladder REACHES it: the store walks DOWN only — the recipient's locale, then its language subtag, then 'en' (packages/services/service-messaging/src/template-renderer.ts, localeCandidates) — and a recipient with no sys_user.locale starts at the deployment default, so an en-US-only row is never used for them. Author locale 'en', or the recipient's exact locale" ], "acceptance": [ { @@ -1516,7 +1548,7 @@ "evidence": "the two probe outcomes" }, { - "clause": "a sys_notification_template renders through a delivery: driving a template-backed channel resolves (topic, channel, locale) from sys_notification_template with locale fallback and renders the subject/body holes — proving the template surface is live and DISTINCT from the inline title/message the inbox flow uses", + "clause": "a sys_notification_template renders through a delivery: driving a template-backed channel resolves (topic, channel, locale) from sys_notification_template along the recipient's locale ladder — down only: the recipient's locale, its language subtag, then 'en'; never up to a regional row — and renders the subject/body holes, proving the template surface is live and DISTINCT from the inline title/message the inbox flow uses", "oracle": "log", "verify": "the captured render carries the template's substituted subject/body (email/sms channel path; messaging-service-plugin 'renders sys_notification_template')", "evidence": "the captured render" @@ -1551,6 +1583,12 @@ "date": "2026-10-13", "change": "knownGaps[1] (new) records the mandatory-topic half of acceptance[3] as blocked(fixture) on stock and names its unit pin (fixture gap, #23166 A3-mandatory; parked on #23121, carried by #23169). mandatoryTopics is a MessagingServicePlugin constructor option, default empty, so the stock showcase declares none and a run cannot add one. QA run #23166 passed the rest of the item. No clause changed", "ref": "#23169" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "steps[6] and acceptance[4].clause corrected (checklist-accuracy finding of QA run #23922, wave #23878). 'with locale fallback' overstated the ladder: the store walks only DOWN (en-US → en → en), so a recipient with no locale resolves the deployment default 'en' and an en-US-only template row is never used — #23922 authored its row at 'en'. The step now says where to author the row, and the clause names the ladder. Not loosened: the render must still carry the template's substituted subject and body", + "ref": "#24036" } ] }, diff --git a/docs/qa/platform-checklist/areas/platform-core.json b/docs/qa/platform-checklist/areas/platform-core.json index 06a13c12f2..68dc41592d 100644 --- a/docs/qa/platform-checklist/areas/platform-core.json +++ b/docs/qa/platform-checklist/areas/platform-core.json @@ -416,7 +416,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": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": ["seeded admin (admin@objectos.ai / admin123)"], @@ -428,19 +428,19 @@ }, "steps": [ "create a writable package (Studio package switcher → new, or POST /api/v1/packages); record its id", - "save a draft view into it: PUT /api/v1/meta/view/os_qa_view_ with a minimal valid ListView body bound to showcase_task and the writable packageId; capture the response", + "save a draft view into it: PUT /api/v1/meta/view/showcase_task.os_qa_view_RUNID?mode=draft&package=PKG with a minimal valid ListView body bound to showcase_task; capture the response. Two spellings matter: a view's name is qualified OBJECT.view (a bare os_qa_view_RUNID is refused), and ?mode=draft is what makes it a draft — a plain PUT saves state=active, which /meta/_drafts never lists", "GET /api/v1/meta/_drafts and confirm the draft is listed", - "publish: POST /api/v1/packages//publish-drafts; then GET /api/v1/meta/view/os_qa_view_/published and capture it", + "publish: POST /api/v1/packages//publish-drafts; then GET /api/v1/meta/view/showcase_task.os_qa_view_RUNID/published and capture it", "verify in the console that the published view is live for showcase_task (reload, open the view switcher)", "denied side A: attempt the same runtime create targeting the read-only CODE package; capture the refusal (expect writable_package_required — ADR-0070 D1)", "denied side B: attempt a PUT overlay against a PACKAGED object item (type 'object' has allowEnvironmentOverlay: false); capture the refusal (expect HTTP 403 not_overridable)", - "Studio browser half: create a new record page via Studio bound to an object and capture the PUT /api/v1/meta/page/... it issues" + "Studio browser half: open the page create form at /_console/apps/showcase_app/metadata/page/new — its Page type defaults to 'list', so set it to 'record' and pick an object (a record page is what gets seeded from the default record layout) — create the page, and capture the PUT /api/v1/meta/page/... it issues" ], "acceptance": [ { "clause": "a draft saved via PUT /api/v1/meta/view/:name persists and is listed by GET /api/v1/meta/_drafts", "oracle": "api", - "verify": "the PUT succeeds; the drafts listing contains os_qa_view_ (routes per packages/runtime/src/route-ledger.ts: PUT /meta/:type/:name, GET /meta/_drafts)", + "verify": "the PUT answers state=draft; the drafts listing contains showcase_task.os_qa_view_RUNID (routes per packages/runtime/src/route-ledger.ts: PUT /meta/:type/:name, GET /meta/_drafts)", "evidence": "PUT response + drafts listing" }, { @@ -470,7 +470,7 @@ { "clause": "a malformed body is refused by schema validation, never stored: a PUT with an invalid shape for the type answers a named validation error (validateOnWrite; the #5206 lesson — an unvalidated store is the defect)", "oracle": "api", - "verify": "PUT /api/v1/meta/view/os_qa_bad_ with a nonsense body (e.g. columns: 42) → 4xx naming the validation failure; a subsequent GET finds no stored item", + "verify": "PUT /api/v1/meta/view/showcase_task.os_qa_bad_RUNID with a nonsense body (e.g. columns: 42) → 4xx naming the validation failure; a subsequent GET finds no stored item", "evidence": "the refusal + the empty GET" } ], @@ -501,7 +501,8 @@ ], "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": 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" } + { "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" }, + { "revision": 3, "date": "2026-10-16", "change": "steps[1], [3] and [7] and acceptance[0].verify and [5].verify re-pointed (checklist-accuracy findings of QA run #23939, wave #23878). A plain PUT /meta/view/NAME saves state=active, so the draft never appeared in /meta/_drafts — ?mode=draft is required — and a view's name must be qualified OBJECT.view (#23939 used showcase_task.os_qa_view_RUNID). The Studio half now names the create form (/_console/apps/showcase_app/metadata/page/new) and its 'list' default, which has to be set to 'record' for a record page. Not loosened: every clause still scores the same server reads", "ref": "#24036" } ] }, { @@ -632,7 +633,7 @@ "title": "Settings hub round-trip: a value saves, PERSISTS, reaches an observable consumer, audits, env-locks, tests honestly, and stores secrets as handles served MASKED — anonymous denied", "since": "v15", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": [ @@ -651,29 +652,29 @@ }, "steps": [ "as admin, GET /api/settings — capture the visible manifests (the 11 open-framework namespaces above; the route is the SettingsService REST surface in settings-routes.ts)", - "pick localization (an OBSERVABLE-CONSUMER namespace — packages/core/src/security/resolve-authz-context.ts reads localization.timezone/locale/currency onto ExecutionContext). GET /api/settings/localization — capture {manifest, values}; note timezone value 'UTC' with source 'default'", - "change it: PUT /api/settings/localization { timezone: 'Asia/Tokyo' }; re-read GET /api/settings/localization and confirm the value persists and source FLIPS default→tenant (localization scope is 'tenant')", - "prove EFFECT (not just the saved form): re-derive an ExecutionContext-dependent output — an analytics date-bucket aggregate or a today()/rendered-datetime on showcase_task — and confirm it now resolves against Asia/Tokyo, not UTC. If the deployment surfaces no observable localization effect, fall back to branding.workspace_name visible in the console shell — but never tick effect off the form alone", + "pick localization (an OBSERVABLE-CONSUMER namespace — packages/core/src/security/resolve-authz-context.ts reads localization.timezone/locale/currency onto ExecutionContext). GET /api/settings/localization — capture {manifest, values}; note the timezone value and its source. On an `os dev --seed-admin` boot the dev seed has ALREADY written the host's zone (UTC in these containers) at tenant scope (packages/plugins/plugin-auth/src/auth-plugin.ts, #23175), so source reads 'tenant' before any PUT; only a boot without the dev seed starts at 'default'", + "change it: PUT /api/settings/localization { timezone: 'Asia/Tokyo' }; re-read GET /api/settings/localization and confirm the value persists and source is the scope, 'tenant' (localization scope is 'tenant'): on a dev-seeded boot the source already read 'tenant', so the value is what round-trips; on an unseeded boot the source flips from 'default'. For the console badge (steps[5]) pick a localization key the dev seed did NOT write (e.g. default_country), so its source starts at 'default'", + "prove EFFECT (not just the saved form): re-derive an ExecutionContext-dependent output and confirm it now resolves against the saved zone, not UTC. Two stock consumers serve: GET /api/v1/auth/me/localization (the resolved timezone the session carries), and a datetime the console renders — e.g. showcase_field_zoo's f_datetime on its record page, which shifts by exactly the zone offset. ⛔ The analytics date bucket has no stock observable (the only date-typed time dimension is due_date, and the seeded admin holds no export grant), so do not reach for it. If no localization effect is observable at all, fall back to branding.workspace_name visible in the console shell — but never tick effect off the form alone", "audit: GET /api/v1/data/sys_setting_audit filtered to namespace=localization,key=timezone — a row exists with action='set', source='api', actor_id=, new_hash set (SettingsServicePlugin.buildAuditWriter → sys_setting_audit)", - "source badge: open /apps/setup/system/settings/localization and confirm the timezone field's source indicator flips from default to the persisted scope after save; screenshot", + "source badge: open /apps/setup/system/settings/localization and confirm a field's source indicator flips from default to the persisted scope after save — use a key whose source was still 'default' before the save (e.g. default_country; on a dev-seeded boot the timezone is already tenant-scoped, steps[1]); screenshot", "env-lock: set OS_LOCALIZATION_TIMEZONE=Europe/Paris (envKeyOf convention: OS__, settings-service.types.ts) and restart; GET /api/settings/localization now reports timezone source='env', locked=true, lockedReason 'Set via env: OS_LOCALIZATION_TIMEZONE', and the console renders the EnvLockBadge; PUT /api/settings/localization { timezone: 'UTC' } is REFUSED 409 SETTINGS_LOCKED (server-side, effectiveEnvOverride)", "test action at the dev transport: POST /api/settings/mail/test with provider=log (or no email plugin mounted) — the handler answers ok:false and NO mail is faked (mail.manifest.ts mailTestActionHandler / plugin-email honest degradation, framework#5087)", "secret handling — AT REST: on a namespace with an encrypted specifier (mail.smtp_password or ai.*_api_key — type 'password' or encrypted:true), PUT a value; then read sys_secret + sys_setting — the ciphertext lands in sys_secret keyed by a 'sec_' handle and sys_setting.value_enc holds the handle id, NOT plaintext", "secret handling — the MASKED READ (positive contract, #7522/PR #7554): GET /api/settings/ and check what IS served, not merely that plaintext is absent — the SET secret's values..value is exactly SETTINGS_SECRET_MASK ('••••••••', eight U+2022 BULLETs — settings-secret-redaction.ts); an UNSET secret's value is still null (the redaction is presence-preserving, and the console renders 'configured vs not configured' from exactly this field); EVERY cascadeChain entry of a secret-backed key is the mask or null (walk a namespace whose scope is 'user' so the chain carries more than one entry); and the whole serialised body contains no plaintext. Do BOTH specifier flavours — type:'password' AND an explicit encrypted:true", "secret handling — metadata survives the redaction: on an env-pinned secret (OS__) confirm the GET still reports source='env', locked=true and lockedReason naming that env var WHILE value is the mask, and that PUT is still refused 409 SETTINGS_LOCKED — masking must not disturb the lock affordances", "secret handling — the ECHOED-MASK round-trip (the half a redaction fix most often breaks): PUT the mask straight back in BOTH shapes the console can send — { : '••••••••' } and the read-shape envelope { values: { : { value: '••••••••' } } } — and confirm 200 plus an UNCHANGED stored secret: re-read it through an in-process consumer (settings client / getNamespace, which is deliberately NOT redacted) and confirm it is still the original plaintext, and that no new sys_secret ciphertext row appeared. Then PUT a genuinely NEW secret: it takes effect, but the PUT RESPONSE is redacted too. Control: a NON-encrypted key whose value genuinely is '••••••••' is written verbatim — the drop is scoped to secret keys and must not swallow a legal write", - "anonymous deny: GET /api/settings/localization with no session → 403 SETTINGS_FORBIDDEN (assertPermitted read); GET /api/settings (list) as anon returns an EMPTY manifest set — zero namespaces enumerated (Finding-1 secure default)", + "anonymous deny: GET /api/settings/localization with no session → 401 UNAUTHENTICATED, and GET /api/settings (list) as anon → 401 UNAUTHENTICATED too — the platform's anonymous floor answers both routes before the settings permission check ever runs (ADR-0056 D2; packages/core/src/security/anonymous-deny.ts, settings-routes.ts), so no namespace is enumerated (Finding-1 secure default)", "guardrails: PUT /api/settings/localization { timezone: 'Mars/Phobos' } → 400 SETTINGS_VALIDATION invalid_option (declared options table); PUT { bogus_key: 1 } → 400 UNKNOWN_KEY" ], "acceptance": [ { - "clause": "a settings value round-trips: PUT /api/settings/localization persists and GET re-reads it, with source FLIPPING default→tenant (the scope), not staying 'default'", + "clause": "a settings value round-trips: PUT /api/settings/localization persists and GET re-reads it, with source reading the scope ('tenant'), never 'default' — on a boot without the dev seed that is a flip from 'default'; on an `os dev --seed-admin` boot the seed already wrote the zone at tenant scope (steps[1]), so the value is what round-trips", "oracle": "api", "verify": "the PUT + GET responses; the timezone value is Asia/Tokyo and source is the scope, not 'default'", "evidence": "the PUT + GET bodies with the source field" }, { - "clause": "EFFECT, not just a form: the saved value reaches its consumer — localization.timezone resolves onto ExecutionContext (resolve-authz-context.ts), so an ExecutionContext-dependent output (analytics date bucket / today() / rendered datetime) shifts from UTC to the saved zone", + "clause": "EFFECT, not just a form: the saved value reaches its consumer — localization.timezone resolves onto ExecutionContext (resolve-authz-context.ts), so an ExecutionContext-dependent output (the session's resolved localization, GET /api/v1/auth/me/localization, or a datetime the console renders — steps[3]) shifts from UTC to the saved zone", "oracle": "api", "verify": "the before/after output pair differs by exactly the zone change; NOTE (ai.json caveat) this clause requires an observable consumer — only localization/branding/auth qualify on stock fixtures, a persisted form is NOT proof", "evidence": "the before/after consumer output" @@ -687,7 +688,7 @@ { "clause": "the console source badge flips from default to the persisted-scope source after save", "oracle": "screenshot", - "verify": "before/after field screenshots of the localization timezone source indicator", + "verify": "before/after field screenshots of the source indicator of a localization key whose source was 'default' before the save (e.g. default_country — on a dev-seeded boot the timezone is already tenant-scoped, steps[5]); the indicator reads the persisted scope after it", "evidence": "the two screenshots" }, { @@ -709,9 +710,9 @@ "evidence": "the action response" }, { - "clause": "an encrypted specifier's value lands in sys_secret as a handle, never plaintext: sys_setting.value_enc holds a 'sec_' handle id and sys_secret holds the ciphertext", + "clause": "an encrypted specifier's value lands in sys_secret as a handle, never plaintext: the settings row's value_enc holds a 'sec_' handle id and sys_secret holds the ciphertext. Which settings table holds the row follows the namespace's scope: a GLOBAL-scope namespace (mail, ai, sms, storage, auth, knowledge) lands in sys_platform_setting, a tenant- or user-scope one in sys_setting", "oracle": "api", - "verify": "the sys_secret row (id starts 'sec_', ciphertext present) + sys_setting.value_enc = that handle (materialiseRow dereferences through sys_secret)", + "verify": "the sys_secret row (id starts 'sec_', ciphertext present) + the settings row's value_enc = that handle, value null (materialiseRow dereferences through sys_secret) — read it from sys_platform_setting for mail.smtp_password / ai.*_api_key (global scope), never from sys_setting, which holds tenant/user rows only", "evidence": "the sys_secret row + sys_setting.value_enc" }, { @@ -733,10 +734,10 @@ "evidence": "the echoed-mask PUT responses + the resolved in-process value + the sys_secret inventory before/after + the new-secret PUT response" }, { - "clause": "anonymous is denied: GET /api/settings/:namespace → 403 SETTINGS_FORBIDDEN, and GET /api/settings lists ZERO namespaces for an unauthenticated caller (no enumeration — Finding-1)", + "clause": "anonymous is denied before any namespace is touched: GET /api/settings/:namespace and GET /api/settings each answer 401 UNAUTHENTICATED for an unauthenticated caller — the anonymous floor (ADR-0056 D2) decides first, so no namespace is enumerated (Finding-1)", "oracle": "api", - "verify": "the two anonymous responses", - "evidence": "the 403 + the empty list" + "verify": "the two anonymous responses: status 401 and error.code UNAUTHENTICATED on each, with no manifest or value in either body. A 200 (any body), or a 403 SETTINGS_FORBIDDEN that means the floor did not run first, is a FAIL", + "evidence": "the two 401 bodies" }, { "clause": "each of the 11 open-framework namespaces resolves GET /api/settings/:ns with its manifest + values — no namespace 500s or serves an empty manifest", @@ -782,7 +783,8 @@ "history": [ { "revision": 1, "date": "2026-08-08", "change": "initial — settings hub round-trip with persistence + observable-consumer effect (honoring the ai.json form-is-not-effect caveat), sys_setting_audit, env-lock server refusal, honest test action, sys_secret handle-not-plaintext, anonymous deny", "ref": "claude/platform-test-checklist-ocwugl" }, { "revision": 2, "date": "2026-08-11", "change": "secret clauses strengthened from negative-only ('never returns the plaintext', which omission and a vanished key also satisfy) to the POSITIVE contract #7522/PR #7554 pinned at the route level: set→mask / unset→null presence-preserving, masking across values..value AND every cascadeChain entry, source/locked/lockedReason unchanged, both specifier flavours, plus the distinct echoed-mask-PUT-is-a-no-op round-trip clause and the in-process-path-is-NOT-masked guard", "ref": "#7573" }, - { "revision": 3, "date": "2026-08-18", "change": "corrected the open-framework namespace count from 10 to 11 in the fixture list, step 0 and the per-namespace clause. builtinSettingsManifests holds 10, but lifecycle is registered separately by ObjectQLPlugin at kernel:ready (packages/objectql/src/lifecycle/lifecycle-settings.ts, 5 specifiers) and is served alongside them — so a runner enumerating only the manifests directory undercounts, and the clause could never be satisfied as written (#9453 CF-7)", "ref": "#9386" } + { "revision": 3, "date": "2026-08-18", "change": "corrected the open-framework namespace count from 10 to 11 in the fixture list, step 0 and the per-namespace clause. builtinSettingsManifests holds 10, but lifecycle is registered separately by ObjectQLPlugin at kernel:ready (packages/objectql/src/lifecycle/lifecycle-settings.ts, 5 specifiers) and is served alongside them — so a runner enumerating only the manifests directory undercounts, and the clause could never be satisfied as written (#9453 CF-7)", "ref": "#9386" }, + { "revision": 4, "date": "2026-10-16", "change": "steps[1], [2], [3], [5] and [12] and acceptance[7] (clause, verify) and [11] (clause, verify, evidence) re-pointed (checklist-accuracy findings of QA run #23939, wave #23878). Four premises had moved: the `os dev --seed-admin` dev seed writes the host zone into localization.timezone at tenant scope (#23175), so the source is 'tenant' before any PUT and the badge flip needs a key the seed did not write (#23939 used default_country); the analytics date bucket has no stock observable, so the effect step names the two consumers #23939 used (auth/me/localization and a rendered datetime); global-scope secrets live in sys_platform_setting.value_enc, not sys_setting; and an anonymous caller meets the ADR-0056 D2 floor first, 401 UNAUTHENTICATED on both routes, not 403 SETTINGS_FORBIDDEN. acceptance[0].clause, [1].clause and [3].verify follow the same premises: the source reads the scope (on a dev-seeded boot it never read 'default', so there is no flip to see), the effect clause names the two stock consumers instead of the analytics bucket, and the badge is read on a key whose source was 'default' before the save. Not loosened: anonymous must still be refused on both routes with no namespace enumerated, and a secret must still never be plaintext", "ref": "#24036" } ] }, { @@ -884,18 +886,18 @@ "title": "Notification center (bell / InboxPopover): badge counts distinct unread topics + pending approvals, repeats coalesce, per-group and global mark-read drop the badge", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "mixed", "personas": ["seeded admin (admin@objectos.ai / admin123)"], "fixtures": { "app": "showcase", "requires": [ - "a recurring notification that produces many identical rows (the showcase_scheduled_digest firing once a minute — the exact flood inboxGrouping.ts was written for), so the topic-coalescing is exercised rather than assumed", + "a recurring notification that produces many identical rows, so the topic-coalescing is exercised rather than assumed. The showcase_scheduled_digest flow (once a minute — the exact flood inboxGrouping.ts was written for) fires only on a boot that exports OS_AUTOMATION_SCHEDULED_WORK_ENABLED=1: by default a stock boot leaves packaged schedules unbound by deployment policy, so without the switch no digest row ever lands. Boot with it exported, or coalesce on the repeated rows the stock seed already delivers (QA run #23939 used the three seeded approval.requested rows) and record which", "at least one pending approval request (sys_approval_request / my_pending) so the approvals portion of the badge and the Approvals tab have something to show" ], "knownGaps": [ - "rows without a notification_id (legacy/synthetic inbox rows) flip read optimistically but do NOT persist server-side — read-state lives in sys_notification_receipt keyed by the L2 event id (ADR-0030), so only rows carrying a notification_id survive reload. Verify persistence on a keyed row, and record synthetic rows as expected-non-persistent" + "rows without a notification_id (legacy/synthetic inbox rows) flip read optimistically but do NOT persist server-side — read-state lives in sys_notification_receipt keyed by the L2 event id (ADR-0030), so only rows backed by a notification_id survive reload. ⚠ The GET /notifications payload carries NO notification_id field: each row's `id` IS that event id when the inbox message has one, else the message's own id (packages/services/service-messaging/src/messaging-service.ts listInbox), so a keyed row cannot be picked out of the payload. Prove persistence on the observable instead — the marked row reads read: true after a reload — and when a row flips back, tell a synthetic row from the receipt bug by reading its backing sys_inbox_message row's notification_id as admin" ] }, "steps": [ @@ -907,7 +909,7 @@ "per-group mark-read: click a group's 'Mark read' → capture POST /api/v1/notifications/read {ids:[...]} on the wire (ONE request for the whole group, not N); the group's unread dot clears and the badge drops by one topic", "global mark-all-read: click 'Mark all read' → capture POST /api/v1/notifications/read/all; the notifications portion of the badge drops to 0; the approvals portion is unaffected", "Approvals tab: confirm it lists/points at the pending approval request(s) and pendingApprovalsCount equals the sys_approval_request my_pending count", - "persistence: reload; read-state persists for rows carrying a notification_id (sys_notification_receipt upsert, ADR-0030); synthetic rows without one flip only optimistically (knownGap)" + "persistence: reload; read-state persists — the marked rows read read: true in the post-reload GET /notifications (sys_notification_receipt upsert, ADR-0030); a row that flips back is either synthetic (its sys_inbox_message row has no notification_id — knownGap) or the receipt bug" ], "acceptance": [ { @@ -949,7 +951,7 @@ { "clause": "read-state PERSISTS across reload for rows carrying a notification_id (sys_notification_receipt upsert, ADR-0030) — after mark-read + reload the keyed row stays read; a mark-read that reverts on the next poll is the pre-ADR-0030 receipt bug", "oracle": "network", - "verify": "the post-reload GET /notifications shows the keyed row still read", + "verify": "the post-reload GET /notifications shows the marked row still read: true; for any row that reverts, the backing sys_inbox_message row's notification_id decides between a synthetic row (none — expected) and the receipt bug (present — FAIL)", "evidence": "the post-reload payload" } ], @@ -969,7 +971,8 @@ "ADR-0030 (sys_inbox_message L5 materialization + sys_notification_receipt); ADR-0103 (receipt object api-method lockdown)" ], "history": [ - { "revision": 1, "date": "2026-08-08", "change": "initial — bell/InboxPopover: topic-based badge (#2765), (topic,title) coalescing, per-group + global mark-read on the wire, approvals tab, receipt persistence; cross-referenced with i18n + approvals notification items", "ref": "claude/platform-test-checklist-ocwugl" } + { "revision": 1, "date": "2026-08-08", "change": "initial — bell/InboxPopover: topic-based badge (#2765), (topic,title) coalescing, per-group + global mark-read on the wire, approvals tab, receipt persistence; cross-referenced with i18n + approvals notification items", "ref": "claude/platform-test-checklist-ocwugl" }, + { "revision": 2, "date": "2026-10-16", "change": "fixtures.requires[0], knownGaps[0], steps[8] and acceptance[6].verify re-pointed (checklist-accuracy findings and fixture gap of QA run #23939, wave #23878, and #23879). The showcase_scheduled_digest fixture never fires on a default boot — OS_AUTOMATION_SCHEDULED_WORK_ENABLED leaves packaged schedules unbound — so the requirement names the switch and the fallback #23939 used (the seeded approval.requested rows). The payload's rows carry no notification_id field — the row id IS the event id when one exists — so 'verify persistence on a keyed row' could not be followed; persistence is proven on read: true after reload, and a reverting row is classified through its backing sys_inbox_message row. Not loosened: a keyed row that reverts is still the receipt bug and FAILS", "ref": "#24036" } ] }, { @@ -977,7 +980,7 @@ "title": "Shell nav personalization: sidebar collapse, pins and the user's pinned order, favorites and recents each persist and stay navigable across reload; the header's crumbs follow the route and rebuild on reload; the authored menu keeps the app's order", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "browser", "personas": ["seeded admin (admin@objectos.ai / admin123)"], @@ -990,9 +993,9 @@ }, "steps": [ "sidebar collapse: toggle the sidebar to icon (collapsed) mode; confirm nav items still NAVIGATE in icon mode; reload — the collapsed state persists (cookie sidebar_state, packages/components/src/ui/sidebar.tsx SIDEBAR_COOKIE_NAME)", - "nav pin/unpin: pin a nav item (UnifiedSidebar → useNavPins.togglePin → favorite id nav:, backed by FavoritesProvider/UserDataAdapter); it appears in the Pinned section; reload — the pin persists (syncs via UserDataAdapter; migrates the legacy objectui-nav-pins key); unpin removes it", + "nav pin/unpin: pin a nav item through that entry's own More actions menu → 'Add to favorites' (there is no hover pin button on the row) (UnifiedSidebar → useNavPins.togglePin → favorite id nav:, backed by FavoritesProvider/UserDataAdapter); it appears in the Pinned section; reload — the pin persists (syncs via UserDataAdapter; migrates the legacy objectui-nav-pins key); unpin through the same menu's 'Remove from favorites' removes it", "pinned order (desktop only): with at least three entries pinned, reorder them inside the Pinned section — the group NavigationRenderer draws above the menu, headed console.nav.favorites ('Favorites' in en, 「收藏」 in zh, a star icon), not the content-favorites group UnifiedSidebar draws below Recent. By pointer: drag a pinned row by the row itself (no grip at rest; the row's link carries cursor-grab; a press becomes a drag only after 5px of travel; a 2px insertion line, data-drop-indicator, marks where it lands). By keyboard: focus a pinned row's link, Space picks it up, the arrow keys move it, Space drops it, Escape cancels; Enter is not a drag key, it follows the link. Reload — the pinned order persists: NavigationRenderer onPinnedReorder → useNavPins.reorderPins → FavoritesProvider reorderNavPins stores it as the order of the type 'nav' favorites (nav:) in the UserDataAdapter-backed favorites list. A new pin joins the end; an unpin leaves the others in order", - "favorites star/unstar: star an object/record as a favorite (useFavorites / FavoritesProvider); it appears in favorites; reload — persists; unstar removes it", + "favorites star/unstar: favorite a record through its record header's More actions menu → 'Add to favorites' (there is no star button in the header) (useFavorites / FavoritesProvider); it appears in favorites; reload — persists; 'Remove from favorites' in the same menu removes it", "recents rail: visit several records/objects/dashboards; the sidebar Recent section (collapsed by default, top 5 — useTrackRouteAsRecent → RecentItemsProvider, localStorage objectui-recent-items scoped by userId, hydrates UserDataAdapter) lists them; reload — recents survive", "header crumbs follow the route (objectui AppHeader at the console pin, objectui#12461 / #12526 / #12273), at a viewport at least 640px wide (the crumbs are drawn from `sm` up): open a report (the sidebar's Hours by Status entry, …/report/showcase_hours_by_status) — after the app pill and its PREVIEW chip the bar draws exactly ONE crumb, the report's label, and no 「Reports」 type crumb; open a system route (…/system/settings) — 'System' then 'Settings'; open a record detail (…//record/) — NO separator and no crumb after the chip, while the record's page header names it. The crumbs are plain text, none of them a link (the app pill's switcher is the way back to the app). Reload each route — the same crumbs rebuild from the route", "authored menu (any viewport): the app's own menu — every row outside the Pinned section — offers no reorder: no drag grip, no aria-roledescription=\"sortable\", no aria-describedby=\"nav-pinned\" (the pinned list's DndContext id, which names dnd-kit's drag instructions) and no cursor-grab on its links, and dragging a menu row moves nothing (a browser's native link-drag ghost is not a reorder). Its order is the app's authored navigation order: each level sorted by order, lower first, ties in authored array order (objectui packages/layout/src/navOrder.ts byNavOrder). Then plant the retired per-user key — localStorage objectui-nav-order- holding a JSON map of __root__ and each group id to that level's ids reversed, the shape the retired useNavOrder read — and reload: the menu order does not change, because UnifiedSidebar no longer reads the key", @@ -1072,6 +1075,12 @@ "date": "2026-10-16", "change": "stale clause re-pointed (#23950, from QA run #23939): acceptance[5] pinned app/section/record breadcrumbs on a RECORD route, each crumb navigable, but objectui#12461 removed every crumb after the app pill on an object route (its list, a view, a record): the page header is the one place that page is named, so the record route drew none and the clause went red against a correct console. Re-pointed to the header contract the console pin (daeb77fc) ships, per objectui packages/app-shell/src/layout/AppHeader.tsx: a report route draws one crumb, the report's label (no type crumb, objectui#12273); a system/* route draws System then its section; an object route draws none (objectui#12461; a dashboard or page route none either, objectui#12526); the crumbs are plain text, so 'each crumb navigates' is withdrawn by that contract, not relaxed; and every route's crumbs rebuild on reload. The clause still fails if a crumb comes back on an object route, if a report or system route loses its crumb, or if a type crumb returns. Step 6, the title, the inert-crumb negative (a non-link crumb is now the design) and the AppHeader source line follow. The pin moved to f8267df9 while this revision was open (#23973); AppHeader there draws the same crumbs after the chip, so the clause is unchanged by it", "ref": "#23950" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "steps[1] and [3] name the affordances that exist (checklist-accuracy residual of QA run #23939, wave #23878; QA run #23998 drove them). A nav entry is pinned through its own More actions menu ('Add to favorites') and a record is favorited through the record header's More actions menu — there is no hover pin button and no star button, so the steps described gestures a runner could not find. The clauses are unchanged", + "ref": "#24036" } ] }, @@ -1080,7 +1089,7 @@ "title": "App management: the launcher/App Switcher filters by active+hidden, isDefault drives post-login landing, and AppManagementPage writes through the metadata API — a code-package app refuses Disable/Set-default with an error toast, and its Delete is a reset:false no-op the page must not report as a deletion", "since": "v15", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "mixed", "personas": [ @@ -1093,7 +1102,7 @@ "a writable package to author an app overlay into (POST /api/v1/packages) so the active/isDefault EFFECT can be exercised at the metadata layer — the seeded showcase/platform apps are read-only code packages (ADR-0070)" ], "knownGaps": [ - "AppManagementPage's Enable/Disable/Set-default/Delete/Bulk handlers write through the metadata API (objectui apps/console/src/pages/system/AppManagementPage.tsx — meta.saveItem → PUT /api/v1/meta/app/{name}, meta.deleteItem → DELETE /api/v1/meta/app/{name}; wired by objectui 3b7d1cc6d7, #4233 / #4300, before the 17.6.0 console pin) and report success only once the call resolves. The seeded showcase/platform apps are read-only code-package apps: on them Disable and Set-default answer 403 NOT_OVERRIDABLE, and Delete answers 200 reset:false, which changes nothing. So the launcher/landing EFFECT is still exercised at the metadata layer on a writable-package overlay, and the page is probed for honest reporting on the refusals" + "AppManagementPage's Enable/Disable/Set-default/Delete/Bulk handlers write through the metadata API (objectui apps/console/src/pages/system/AppManagementPage.tsx — meta.saveItem → PUT /api/v1/meta/app/{name}, meta.deleteItem → DELETE /api/v1/meta/app/{name}; wired by objectui 3b7d1cc6d7, #4233 / #4300, before the 17.6.0 console pin) and report success only once the call resolves. The seeded showcase/platform apps are read-only code-package apps: on them Disable and Set-default answer 403 ITEM_LOCKED (the write names the app's read-only base package — ADR-0010 lock), and Delete answers 200 reset:false, which changes nothing; at the console pin the page reads that reset:false and says so in a neutral toast (QA run #23939 read 'Nothing to delete for Showcase'). So the launcher/landing EFFECT is still exercised at the metadata layer on a writable-package overlay, and the page is probed for honest reporting on the refusals" ] }, "steps": [ @@ -1102,8 +1111,8 @@ "prove DEFAULT→landing: the isDefault app drives post-login landing — confirm login lands on the current default app, and that changing which app is isDefault (at the metadata layer) changes the post-login landing route", "member view: as a plain member the App Switcher shows only accessible apps (Setup/Studio absent by App.requiredPermissions); disabled/hidden apps also absent", "open Setup → Apps → Applications (AppManagementPage) as admin; it lists configured apps with Active/Default badges and per-row controls", - "honest-reporting probe on a seeded (code-package) app: click Disable in AppManagementPage → the page issues PUT /api/v1/meta/app/{name}, which answers 403 NOT_OVERRIDABLE; an error toast carries the server's reason, and GET /api/v1/meta/app shows the app unchanged on reload", - "same probe for Set-default (PUT → 403 NOT_OVERRIDABLE, error toast, metadata unchanged) and Delete (DELETE /api/v1/meta/app/{name} → 200 with reset:false, a no-op: the app is still listed on reload, and the page must NOT report it deleted). Then click Disable on the writable-package app from step 2 and confirm the toggle takes real effect (the launcher drops it)" + "honest-reporting probe on a seeded (code-package) app: click Disable in AppManagementPage → the page issues PUT /api/v1/meta/app/{name}, which answers 403 ITEM_LOCKED; an error toast carries the server's reason, and GET /api/v1/meta/app shows the app unchanged on reload", + "same probe for Set-default (PUT → 403 ITEM_LOCKED, error toast, metadata unchanged) and Delete (DELETE /api/v1/meta/app/{name} → 200 with reset:false, a no-op: the app is still listed on reload, and the page must NOT report it deleted — it shows a neutral nothing-to-delete toast). Then click Disable on the writable-package app from step 2 and confirm the toggle takes real effect (the launcher drops it)" ], "acceptance": [ { @@ -1137,9 +1146,9 @@ "evidence": "the page screenshot" }, { - "clause": "AppManagementPage writes through PUT/DELETE /api/v1/meta/app/{name}: on a code-package app Disable/Set-default return 403 NOT_OVERRIDABLE with an error toast and /meta/app is unchanged on reload; Delete returns 200 reset:false (a no-op) and the page must not report the app as deleted.", + "clause": "AppManagementPage writes through PUT/DELETE /api/v1/meta/app/{name}: on a code-package app Disable/Set-default return 403 ITEM_LOCKED with an error toast and /meta/app is unchanged on reload; Delete returns 200 reset:false (a no-op) and the page must not report the app as deleted.", "oracle": "network", - "verify": "the network trace shows the PUT for Disable and for Set-default answering 403 NOT_OVERRIDABLE and the DELETE answering 200 with reset:false; an error toast for each refused PUT; /meta/app unchanged on reload; after the Delete, no 'deleted' success and the app still listed. ⚠️ At objectui 9dfaca6543 handleDelete shows the '{name} deleted' success toast whenever deleteItem resolves, without reading reset, so expect the Delete half to FAIL there (the open candidate the run's verifier recorded). Score it as observed; do not soften the clause", + "verify": "the network trace shows the PUT for Disable and for Set-default answering 403 with error.code ITEM_LOCKED and the DELETE answering 200 with reset:false; an error toast for each refused PUT; /meta/app unchanged on reload; after the Delete, no 'deleted' success and the app still listed. At the console pin handleDelete reads reset (objectui apps/console/src/pages/system/AppManagementPage.tsx): on reset:false it shows a neutral toast carrying the server's own sentence and no success — a '{name} deleted' success after a reset:false answer is the FAIL. Score the status and code, never the toast wording", "evidence": "the three write traces + the toasts + the unchanged meta + the post-Delete page" } ], @@ -1158,7 +1167,8 @@ ], "history": [ { "revision": 1, "date": "2026-08-08", "change": "initial — launcher active+hidden filter and isDefault→landing proven at the metadata layer, with the AppManagementPage toggle recorded as a client-only stub (expected-fail probe) rather than faked", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-10-05", "change": "clause 6, negative 1, the stub knownGap, steps 2, 6 and 7 and the title re-pointed (stale, #21784 VF5). objectui 3b7d1cc6d7 (#4233 / #4300, 2026-08-11) wired AppManagementPage to real saveItem / deleteItem before the 17.6.0 console pin, so the 'client-only stub' clause was already stale at 17.6.0. The clause uses the verifier's wording, and negative 1 is derived from it: the page writes through PUT/DELETE /api/v1/meta/app/{name}, a code-package app refuses Disable/Set-default with 403 NOT_OVERRIDABLE, and Delete answers 200 reset:false, a no-op. The 'must not report the app as deleted' half is expected to fail at objectui 9dfaca6543, because handleDelete toasts success without reading reset; the clause says so rather than softening. The member persona now reads invite + sign-up (stock invite_only)", "ref": "#21797" } + { "revision": 2, "date": "2026-10-05", "change": "clause 6, negative 1, the stub knownGap, steps 2, 6 and 7 and the title re-pointed (stale, #21784 VF5). objectui 3b7d1cc6d7 (#4233 / #4300, 2026-08-11) wired AppManagementPage to real saveItem / deleteItem before the 17.6.0 console pin, so the 'client-only stub' clause was already stale at 17.6.0. The clause uses the verifier's wording, and negative 1 is derived from it: the page writes through PUT/DELETE /api/v1/meta/app/{name}, a code-package app refuses Disable/Set-default with 403 NOT_OVERRIDABLE, and Delete answers 200 reset:false, a no-op. The 'must not report the app as deleted' half is expected to fail at objectui 9dfaca6543, because handleDelete toasts success without reading reset; the clause says so rather than softening. The member persona now reads invite + sign-up (stock invite_only)", "ref": "#21797" }, + { "revision": 3, "date": "2026-10-16", "change": "knownGaps[0], steps[5] and [6] and acceptance[5] (clause, verify) re-pointed (checklist-accuracy findings of QA run #23939, wave #23878). Disable and Set-default on a code-package app answer 403 ITEM_LOCKED — the page's write names the app's read-only base — not NOT_OVERRIDABLE; and the console now reads reset:false on Delete and shows a neutral nothing-to-delete toast, so the verify text's 'expect the Delete half to FAIL' note (objectui 9dfaca6543) is stale. Not loosened: a 2xx on either refused write, a missing error toast, or a 'deleted' success after reset:false each still FAIL", "ref": "#24036" } ] }, { @@ -1359,7 +1369,7 @@ "title": "The package manifest is enforced at the install boundary: an incompatible `engines` range is refused before the registry write, the namespace gate holds ownership, and a namespace-less runtime package has one derived", "since": "v15", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -1381,13 +1391,13 @@ }, "steps": [ "boot showcase isolated; sign in as admin. Read the runtime's protocol version from the running server (the boot banner / GET /api/v1/meta), take its leading major as RT, and derive every range below from RT — never from a literal", - "BASELINE: POST /api/v1/packages with { manifest: { id: 'com.example.qa-manifest-probe-a', name: 'Manifest Probe A', version: '1.0.0', scope: 'custom', type: 'app', namespace: 'qa_mfp', engines: { protocol: '^RT' } } }; capture status + body and GET it back — this is the compatible control every refusal below is judged against", + "BASELINE: POST /api/v1/packages with { manifest: { id: 'com.example.qa-manifest-probe-a', name: 'Manifest Probe A', version: '1.0.0', scope: 'project', type: 'app', namespace: 'qa_mfp', engines: { protocol: '^RT' } } }; capture status + body and GET it back — this is the compatible control every refusal below is judged against. Every probe manifest below carries the same scope 'project' (the manifest's scope enum is cloud | system | project, default project — packages/spec/src/kernel/manifest.zod.ts; a scope such as 'custom' is refused 400 VALIDATION_ERROR before any gate this item scores is reached)", "INCOMPATIBLE: POST id 'com.example.qa-manifest-probe-old' with engines: { protocol: '^(RT-1)' }; capture the refusal IN FULL (status, code, message, rangeSource, targetMajor, migrateCommand). Then GET /api/v1/packages/com.example.qa-manifest-probe-old and GET /api/v1/packages and confirm NO row exists for that id", "PRECEDENCE, both directions: POST 'com.example.qa-manifest-probe-prec-ok' with engines: { protocol: '^RT', platform: '^(RT-1)' } → expect install (protocol is consulted first, so the incompatible platform range is never reached); POST 'com.example.qa-manifest-probe-prec-no' with engines: { protocol: '^(RT-1)', platform: '^RT' } → expect refusal whose diagnostic names `engines.protocol` as the range source", - "LEGACY leg: POST 'com.example.qa-manifest-probe-legacy' with NO `engines` and engine: { objectstack: '^(RT-1)' } → expect refusal whose diagnostic names `engine.objectstack` as the source; repeat with '^RT' → expect install. This proves the third precedence rung is wired, not merely typed", + "LEGACY leg: POST 'com.example.qa-manifest-probe-legacy' with NO `engines` and engine: { objectstack: '^(RT-1).0.0' } → expect refusal whose diagnostic names `engine.objectstack` as the source; repeat with '^RT.0.0' → expect install. The legacy range must be a FULL semver (the manifest schema's engine.objectstack regex wants major.minor.patch): a bare '^(RT-1)' is refused 400 VALIDATION_ERROR at parse and never reaches the handshake. This proves the third precedence rung is wired, not merely typed", "GRANDFATHERED: POST 'com.example.qa-manifest-probe-norange' with no `engines` and no `engine` at all → expect install, plus the server's `[protocol] package '…' declares no engines.protocol range` warning in the log", "UNPARSED: POST 'com.example.qa-manifest-probe-wsrange' with engines: { protocol: 'workspace:*' } → expect install, plus the `declares an unrecognized engines.protocol range` warning. This is the direction that would break every workspace-linked package if it inverted", - "NAMESPACE OWNERSHIP, three legs: (a) POST 'com.example.qa-manifest-probe-b' declaring namespace 'qa_mfp' — the one probe A already owns → expect refusal naming the namespace, the current owner and the incoming id; (b) re-POST probe A's OWN manifest unchanged (same id, same namespace) → expect NO conflict (reinstall/HMR is a normal path); (c) POST 'com.example.qa-manifest-probe-shared' declaring a shareable platform namespace (base / system / sys) → expect NO conflict (shareable namespaces are exempt)", + "NAMESPACE OWNERSHIP, three legs: (a) POST 'com.example.qa-manifest-probe-b' declaring namespace 'qa_mfp' — the one probe A already owns → expect refusal naming the namespace, the current owner and the incoming id; (b) re-POST probe A's OWN manifest unchanged (same id, same namespace) as a reinstall — POST /api/v1/packages?overwrite=true, since a plain re-POST of an installed id meets the install door's own already-installed refusal (api-backend.package-rest-lifecycle) before the namespace gate — → expect NO namespace conflict (reinstall/HMR is a normal path); (c) POST 'com.example.qa-manifest-probe-shared' declaring a shareable platform namespace (base / system / sys) → expect NO conflict (shareable namespaces are exempt)", "NAMESPACE DERIVATION: POST id 'com.example.leave' with NO namespace declared → GET it back and read manifest.namespace; then POST 'com.example.holiday' WITH an explicit namespace 'timeoff' → GET it back and confirm the declared value survived untouched", "teardown: DELETE every scratch package created above, or simply discard the isolated file DB — the cheaper path an isolated boot makes free. ⚠️ Several legs here are NOT re-runnable in place (step 8b depends on probe A still being installed, and 8a stops being a conflict once probe A is gone), so a re-run starts from a clean DB rather than from the tail of the previous one" ], @@ -1461,6 +1471,12 @@ "date": "2026-09-20", "change": "the scratch probe ids these steps POST are rewritten to reverse-domain spellings (com.example.qa-manifest-probe-*) — `ManifestSchema.id` now carries `MANIFEST_ID_PATTERN`, so the underscored spellings this item used to name are refused by every door but the HTTP install door, and a checklist that teaches one is the trap the pattern was added to close. Steps, legs and expected verdicts are otherwise unchanged", "ref": "#18319" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "steps[1], [4] and [7](b) re-pointed (checklist-accuracy findings of QA run #23939, wave #23878). The probe manifests used scope 'custom', which POST /packages refuses (the enum is cloud | system | project), so the baseline never installed; the legacy engine.objectstack range must be full semver ('^17.0.0') because a bare major is a 400 VALIDATION_ERROR at parse; and #23939 drove the own-namespace reinstall leg as POST ?overwrite=true. Not loosened: every refusal and admission the clauses score is unchanged", + "ref": "#24036" } ] }, @@ -1469,7 +1485,7 @@ "title": "The activation ledger's registration home: sys_metadata_activation is registered by PlatformObjectsPlugin under its OWN manifest, so packaged disable works with or without the automation service — one owner, datasource binding carried across the move", "since": "v17", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "api", "personas": ["seeded admin (admin@objectos.ai / admin123)"], @@ -1513,7 +1529,7 @@ { "clause": "the datasource binding is carried by the registrar, unchanged by the move: the owning ACTIVATION_LEDGER_MANIFEST carries the automation manifest's scope/namespace/defaultDatasource triple verbatim (scope 'system', defaultDatasource 'cloud', namespace 'sys'), so on a composition with no 'cloud' driver the table rides the global default driver exactly as before — a binding change would leave existing rows in one database and read another, silently re-arming every artifact an administrator disabled (ADR-0126 §6 wall 3 through a side door)", "oracle": "test", - "verify": "the dogfood binding test: resolveEffectiveDatasource('sys_metadata_activation') === undefined on the no-cloud composition, with sys_automation_run as the control proving the resolver is not answering undefined for everything; the manifest triple itself is pinned in platform-objects/src/plugin.test.ts where it is visible without a cloud driver existing", + "verify": "the dogfood binding test: resolveEffectiveDatasource('sys_metadata_activation') === undefined on the no-cloud composition. ⚠ The test also reads sys_automation_run, and asserts it is undefined TOO — a same-answer parity check, not an anti-vacuity control: it cannot show the resolver is not answering undefined for everything, so do not cite it as one. The binding claim rests on the manifest triple pinned in platform-objects/src/plugin.test.ts, where it is visible without a cloud driver existing; record the sys_automation_run read as parity only", "evidence": "the vitest output naming both assertions" }, { @@ -1567,6 +1583,12 @@ "date": "2026-10-09", "change": "the no-automation composition is restated as the dogfood suite now builds it. #22301 made bootStack compose what `os serve` composes, the app's own `requires` included, so `bootStack(showcaseStack)` with the automation option omitted now mounts the automation service and no longer stages the #12359 composition. The suite boots a showcase-derived configuration that does not declare automation instead: the 'automation' token taken out of `requires`, and the app plugins that depend on the automation service left out. fixtures.requires, step 1, the harness source entry and revision 1's history note are re-said to match. Steps, legs, clauses and expected verdicts are otherwise unchanged, and the anti-vacuity control is still the suite's first test", "ref": "#22301" + }, + { + "revision": 5, + "date": "2026-10-16", + "change": "acceptance[3].verify corrected (checklist-accuracy finding of QA run #23939, wave #23878). It called sys_automation_run 'the control proving the resolver is not answering undefined for everything', but the dogfood test asserts sys_automation_run is undefined as well — a same-answer parity check that cannot discriminate. The verify text now says so and rests the binding claim on the manifest-triple pin it already cited; the clause's assertion is unchanged", + "ref": "#24036" } ] }, @@ -1575,7 +1597,7 @@ "title": "sys_metadata_activation row contract: reads open / generic-data-API writes 405, one row per (metadata_type, name) with NO tenant column on the table at all, re-enable updates never deletes, and rows exist ONLY for the metadata types a shipped door writes — flow, action, position, permission (the ADR-0126 scope wall, as ADR-0131 D3 widens it)", "since": "v17", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "api", "personas": ["seeded admin (admin@objectos.ai / admin123)"], @@ -1597,14 +1619,14 @@ "write-refusal probes, all three verbs: POST /api/v1/data/sys_metadata_activation {metadata_type:'action',name:'qa_probe',package_id:'qa',active:false}, PATCH /api/v1/data/sys_metadata_activation/ {active:true}, DELETE /api/v1/data/sys_metadata_activation/ — each answers 405 naming the refused operation and the allowed set; re-read confirms every row unchanged", "re-enable every switch flipped in step 1 — the SAME rows update to active true (same ids, row count unchanged, nothing vanished)", "uniqueness probe (direct DB): stop the server; in the sqlite file INSERT a second row for an existing (metadata_type, name) pair — the declared unique:'global' index over exactly those two columns refuses with a UNIQUE constraint error; capture it. Both key parts are required text, so no NULL arm is left to collapse: the earlier unique:'organization' spelling asked the driver to prepend the tenant column in its COALESCE'd form (ADR-0120 D3) against the #5030 NULL-distinct hole, and with the column gone normalizeDeclaredIndex resolves tenantField to null — so the materialized DDL is byte-identical either way and the current spelling is a re-SPELLING, not a change of shape (sys-metadata-activation.object.ts records it)", - "no-tenant-column probe (direct DB, server still stopped): read the physical schema — PRAGMA table_info(sys_metadata_activation) — and record the column list: metadata_type, name, package_id and active are present beside the driver's id and the injected audit family (created_at / created_by / updated_at / updated_by), and NOTHING tenant-shaped is — organization_id is absent from the list entirely. Then attempt INSERT INTO sys_metadata_activation (id, metadata_type, name, package_id, active, organization_id) VALUES ('qa_probe_no_tenant','action','qa_probe_no_tenant','qa',0,'org_qa') — sqlite refuses with `table sys_metadata_activation has no column named organization_id` and no row lands. CONTROL (anti-vacuity, same DB): the SAME statement with the organization_id column and its value dropped SUCCEEDS — so the refusal names the missing column, not a malformed statement and not the unique index (the (metadata_type, name) pair is fresh on purpose, so step 6's index cannot be what answered). DELETE the control row, then restart against the SAME DB and confirm the ledger reads exactly as it did before the probe", + "no-tenant-column probe (direct DB, server still stopped): read the physical schema — PRAGMA table_info(sys_metadata_activation) — and record the column list: metadata_type, name, package_id and active are present beside the driver's id, the injected audit family (created_at / created_by / updated_at / updated_by) and the search companion column __search (the object is searchable — packages/objectql/src/search-companion.ts#SEARCH_COMPANION_FIELD), and NOTHING tenant-shaped is — organization_id is absent from the list entirely. Then attempt INSERT INTO sys_metadata_activation (id, metadata_type, name, package_id, active, organization_id) VALUES ('qa_probe_no_tenant','action','qa_probe_no_tenant','qa',0,'org_qa') — sqlite refuses with `table sys_metadata_activation has no column named organization_id` and no row lands. CONTROL (anti-vacuity, same DB): the SAME statement with the organization_id column and its value dropped SUCCEEDS — so the refusal names the missing column, not a malformed statement and not the unique index (the (metadata_type, name) pair is fresh on purpose, so step 6's index cannot be what answered). DELETE the control row, then restart against the SAME DB and confirm the ledger reads exactly as it did before the probe", "record the operability posture: absence of a row reads as ACTIVE (an empty ledger is the normal stock state, not an error), and the data-API read IS the only 'what is off here' surface today — ObjectQLEngine.listDisabledActions() (packages/objectql/src/engine.ts) has zero route consumers" ], "acceptance": [ { "clause": "reads open, generic writes refused: GET /api/v1/data/sys_metadata_activation answers 200 for an admin (operability surfaces can ask 'what is disabled here'), while create/update/delete through the generic data API each answer 405 naming the refused operation — the rows are written by the ADR-0126 enable/disable doors under a system context and by nothing else (ADR-0103 engine-owned posture)", "oracle": "api", - "verify": "the 200 list read + the three 405s from step 4 (apiMethods ['get','list'] at sys-metadata-activation.object.ts; the 405-with-allowedOperations shape from checkApiExposure, packages/runtime/src/api-exposure.ts); the post-probe re-read diffs empty", + "verify": "the 200 list read + the three 405s from step 4, each naming the refused operation and an allowed set of exactly [get, list, aggregate, search, export] — the declared apiMethods ['get','list'] plus the read verbs `list` derives (packages/spec/src/data/api-derivation.ts#API_METHOD_DERIVATION: aggregate, export and search are derived from list), as checkApiExposure computes it (packages/runtime/src/api-exposure.ts); the post-probe re-read diffs empty. An allowed set that holds create, update or delete is a FAIL", "evidence": "the list read + the three refusal bodies + the empty diff" }, { @@ -1614,7 +1636,7 @@ "evidence": "the row dump + the refused INSERT with its constraint error" }, { - "clause": "the ledger carries NO tenant column, and cannot be handed a row that has one: PRAGMA table_info lists the four ledger columns beside id and the audit family with nothing tenant-shaped among them, and an INSERT naming organization_id is refused by sqlite while the same INSERT without it lands. A row here is DEPLOYMENT-level state no organization owns (ADR-0131 D7, which WITHDREW ADR-0126 D3's reserved-and-never-written column before 17.3), so there is no per-organization row left for a read to merge OR to skip", + "clause": "the ledger carries NO tenant column, and cannot be handed a row that has one: PRAGMA table_info lists the four ledger columns beside id, the audit family and the __search companion column, with nothing tenant-shaped among them, and an INSERT naming organization_id is refused by sqlite while the same INSERT without it lands. A row here is DEPLOYMENT-level state no organization owns (ADR-0131 D7, which WITHDREW ADR-0126 D3's reserved-and-never-written column before 17.3), so there is no per-organization row left for a read to merge OR to skip", "oracle": "log", "verify": "step 7 both arms: the PRAGMA column list with organization_id absent, then the refused INSERT quoting sqlite's `has no column named organization_id`, bracketed by the control INSERT that succeeds once the column is dropped from the statement. Ground the DECLARATION side against systemFields { tenant: false } at sys-metadata-activation.object.ts — the opt-out that REMOVES the column rather than one that leaves it unwritten, since resolveInjectedSystemColumns would provision it by injection with no field declared at all. ⛔ Never score this clause from a row-VALUE read (see step 2)", "evidence": "the PRAGMA column list + the refused INSERT with sqlite's message + the successful control INSERT" @@ -1689,6 +1711,12 @@ "date": "2026-10-16", "change": "stale clause re-pointed (#23950, from QA run #23939): acceptance[4] held the scope wall at {flow, action} and named a position or permission row a FAIL of the model, on the 2026-08-26 #12159 ruling that a permission set rides its own active field. ADR-0131 D3 superseded that: the on/off switch of a position and of a permission set moved off the catalog row into this ledger, read by readDisabledCatalogNames, and the shipped door POST /api/v1/security/_activation/:type/:name (packages/runtime/src/domains/catalog-activation.ts, ADR-0126 §3 regime C as ADR-0131 D6 amends it) writes those rows — so a stock switch wrote a legitimate 'position' row and the clause went red against a correct product. The item's own knownGap asked for exactly this revision when a third writer shipped. The wall is now the four values the shipped doors write, still hand-enumerated, and it gains a door-side leg: a :type outside position/permission (capability) and an audience anchor (everyone) are refused 400 with no row, so the clause still fails if a fifth type or an anchor gets a row. Step 1 flips all four switches and step 5 re-enables them (acceptance[3] follows); the variants, the knownGap's writer list, the title, a negative and the sources follow, and the #12159 citation is marked superseded", "ref": "#23950" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "acceptance[0].verify, steps[6] and acceptance[2].clause corrected (residual checklist-accuracy finding of QA run #23939, wave #23878; PR #24000 re-pointed clause 4). The 405's allowed set is [get, list, aggregate, search, export] — the declared apiMethods ['get','list'] plus the verbs list derives (API_METHOD_DERIVATION) — not the bare apiMethods pair, and PRAGMA table_info also lists the __search companion column the searchable object carries. The verify text now names the allowed set exactly (a write verb in it FAILS) and the column list names __search. Not loosened: organization_id must still be absent and every generic write still 405", + "ref": "#24036" } ] }, @@ -1697,7 +1725,7 @@ "title": "Packaged objects are extend-only (Regime E): an extension package's fields merge additively and round-trip on showcase_account, while an in-place field add on the packaged object is refused server-side at both write doors — and the collision-refusal half of the docs claim does not exist yet", "since": "v16", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "api", "personas": ["seeded admin (admin@objectos.ai / admin123)"], @@ -1713,7 +1741,7 @@ "steps": [ "additive half — CROSS-REF, do not duplicate: platform-core.metadata-registry-serving owns the overlay steps (its steps 7-8 / clauses 5-6): the three AccountExtension fields present in the MERGED GET /api/v1/meta/object/showcase_account, rendering on the form, and a loyalty_tier + csat_score write round-tripping through /api/v1/data/showcase_account. Cite that item's verdicts when the same run scored them; otherwise drive exactly those steps here", "capture the BEFORE image for the refusal half: GET /api/v1/meta/object/showcase_account and keep the full served field inventory", - "in-place refusal, door 1 (org-overlay): PUT /api/v1/meta/object/showcase_account with the served body plus ONE added field qa_inplace_probe_ and NO ?package= → expect 403 NOT_OVERRIDABLE (type 'object' is not in the ADR-0005 overlay opt-ins; the refusal names allowRuntimeCreate/allowEnvironmentOverlay and the OS_METADATA_WRITABLE escape hatch — packages/metadata-protocol/src/protocol.ts)", + "in-place refusal, door 1 (org-overlay): PUT /api/v1/meta/object/showcase_account with the served body plus ONE added field qa_inplace_probe_ and NO ?package= → expect 403 NOT_OVERRIDABLE (type 'object' is not in the ADR-0005 overlay opt-ins — packages/metadata-protocol/src/protocol.ts). The refusal's sentence now says the item is provided by a managed package and is sealed (ADR-0131 D6: no escape hatch opens a packaged item); score the status and error.code, not its wording", "in-place refusal, door 2 (package write): repeat the same PUT with ?package=com.example.showcase (the showcase's read-only code package) → expect the read-only-base refusal (403 ITEM_LOCKED class — the same door access-security.owd-save-gate's control leg measured on stock, #9477); record WHICH door answered and the exact status + code, reading declaredCode where the wire code is a mapped generic", "after both refusals: re-GET /api/v1/meta/object/showcase_account and diff against step 2 — byte-identical; qa_inplace_probe_ absent, the three extension fields still present", "record the Regime E provenance posture: the extension arrived AS A PACKAGE — it registers as an 'extend' contributor (packages/objectql/src/engine.ts) folded over the base at resolution (mergeObjectDefinitions), the base package's authored definition is never edited, and package identity supplies provenance and upgrade isolation (ADR-0126 §3: 'the customization IS a package', install-grain not per-org)" @@ -1749,7 +1777,7 @@ "examples/app-showcase/src/data/extensions/account.extension.ts#AccountExtension (AccountExtension — the shipped fixture, priority 210)", "packages/objectql/src/registry.ts#mergeObjectDefinitions (mergeObjectDefinitions — additive fields fold, the spread) + vicinity via engine: packages/objectql/src/engine.ts#registerApp (the objectExtensions loop registering 'extend' contributors — re-pointed #18104: objectExtensions is read off the manifest here, a member access; the loop this item means runs inside registerApp, which this file declares)", "packages/spec/src/data/object.zod.ts#ObjectExtensionSchema (ObjectExtensionSchema — what an extension may carry, the #4001 silently-dropped history, 'Fields to add/override', priority wins-on-conflict; defineObjectExtension)", - "packages/metadata-protocol/src/protocol.ts#refusal (the NOT_OVERRIDABLE refusal text and escape hatch)", + "packages/metadata-protocol/src/protocol.ts#refusal (the NOT_OVERRIDABLE refusal: a packaged item is sealed by its managed package, ADR-0131 D6)", "packages/plugins/plugin-auth/src/managed-extension-fields.ts + managed-extension-fields.test.ts (the build-time collision gate — better-auth-managed objects ONLY; adopted by ADR-0126 §3 as Regime E prior art, NOT a generic gate)", "docs/adr/0126-packaged-metadata-customization-model.md §2-§3 (Regime E: additive contributions at package grain, base never edited, install-grain not per-org)", "content/docs/capabilities/integrations.mdx + content/docs/build-without-code.mdx (the E1 docs claim this item grounds)", @@ -1762,6 +1790,12 @@ "date": "2026-08-26", "change": "new — authored in the #12438 scoped sweep to ground the E1 tier-3 docs claim. Grounding CORRECTED the claim's third half before authoring: no collision refusal exists for generic object extensions (mergeObjectDefinitions spreads extension fields over the base and the schema documents override-by-priority; the managed-extension-fields build gate covers better-auth objects only and is adopted by ADR-0126 §3 as prior art, not as a live gate) — so that half is a knownGap with an explicit do-not-file-as-FAIL rule, and the item asserts the two halves source supports: additive merge + round-trip (cross-referenced to metadata-registry-serving, not duplicated) and the server-side in-place refusal at both write doors", "ref": "#12438" + }, + { + "revision": 2, + "date": "2026-10-16", + "change": "steps[2] and source[3] corrected (checklist-accuracy finding of QA run #23939, wave #23878, and #23879). The NOT_OVERRIDABLE refusal no longer names allowRuntimeCreate/allowEnvironmentOverlay and the OS_METADATA_WRITABLE escape hatch; it says, in one sentence, that the item is provided by a managed package and is sealed (ADR-0131 D6). The step now scores the status and code it always expected and describes the sentence without pinning it. Wording only: no request or oracle changed", + "ref": "#24036" } ] }, @@ -1770,7 +1804,7 @@ "title": "Marketplace install-local: the wiring matrix mounts the right arms per OS_CLOUD_URL, the air-gapped inline install works with NO control plane, reseed/purge touch exactly the sample rows, every mutating door is manage_metadata-gated both sides, and the listing projects installedBy/storageDir per principal", "since": "v17", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "api", "personas": [ @@ -1796,8 +1830,8 @@ "boot B (OS_CLOUD_URL=off), as admin: GET /api/v1/runtime/config → still served, now reporting installLocal:true AND marketplace:false (#8389 discovery + #8356 route-table derivation); GET /api/v1/marketplace/install-local → 200 []; a browse-namespace probe (e.g. GET /api/v1/marketplace/packages) → 404 (the proxy did NOT mount); GET /api/v1/cloud-connection/status → 404 (cloud-connection did NOT mount)", "build the artifact: pnpm -C examples/app-crm build; confirm dist/objectstack.json exists", "air-gapped install against boot B: `os package install./examples/app-crm/dist/objectstack.json` with admin credentials — the CLI signs in, then POSTs the manifest inline to /api/v1/marketplace/install-local (install.ts); capture the success output and the installed entry", - "confirm the install registered: GET /api/v1/meta/app lists the CRM app; a CRM object answers on /api/v1/data; GET /api/v1/marketplace/install-local as admin shows the entry WITH installedBy and storageDir", - "per-principal projection (#9011): repeat the GET as the plain member — 200, the entry listed, but installedBy/storageDir ABSENT from the body; repeat anonymous — 401", + "confirm the install registered: GET /api/v1/meta/app lists the CRM app; a CRM object answers on /api/v1/data; GET /api/v1/marketplace/install-local as admin shows the entry WITH installedBy, and storageDir on the listing envelope (data.storageDir — one value for the whole ledger, not a per-entry field)", + "per-principal projection (#9011): repeat the GET as the plain member — 200, the entry listed, but installedBy ABSENT from the entry and storageDir ABSENT from the envelope; repeat anonymous — 401", "reseed/purge on the installed entry: create ONE user-authored row in an installed CRM object and record its id; POST /api/v1/marketplace/install-local/:manifestId/purge-sample-data as admin — capture {deleted, skipped, errors}; re-read: every manifest-declared seed id is gone, the user-authored row SURVIVES (the purge deletes only ids declared in the cached manifest's seed datasets — marketplace-install-local-plugin.ts); restart boot B against the same DB and confirm the purged rows do NOT come back (sampleDataPurged persisted on the ledger entry)", "POST …/:manifestId/reseed-sample-data as admin — the sample rows return and sampleDataPurged flips back off", "capability gate both sides (#8976): as the plain member, POST install-local (any body), DELETE …/:manifestId, POST reseed, POST purge — each 403 naming the manage_metadata requirement; anonymous on each → 401; the admin's same calls succeeded above", @@ -1823,7 +1857,7 @@ "evidence": "CLI transcript + the meta/app listing + one data read" }, { - "clause": "the listing is per-principal (#9011): admin sees installedBy + storageDir on each entry; an authenticated NON-manage_metadata member gets the entry WITHOUT those two fields; anonymous gets 401 — presence-preserving projection, not a hidden route", + "clause": "the listing is per-principal (#9011): admin sees installedBy on each entry and storageDir on the listing envelope (data.storageDir); an authenticated NON-manage_metadata member gets the entry WITHOUT installedBy and an envelope WITHOUT storageDir — both keys omitted, never nulled; anonymous gets 401 — presence-preserving projection, not a hidden route (packages/cloud-connection/src/marketplace-install-local-plugin.ts, handleList)", "oracle": "api", "verify": "the three GET /api/v1/marketplace/install-local bodies side by side; the member's body must still LIST the entry (a 403 or an empty list for the member is a different, wrong behavior)", "evidence": "the three response bodies" @@ -1881,6 +1915,12 @@ "date": "2026-10-13", "change": "fixtures.requires[1] gained the scratch-copy rule (setup recipe gap, #23104; recorded on platform-core.marketplace-console-honesty, which names this item, and carried by #23169). install-local caches the installed manifest relative to the server's working directory (local-manifest-source.ts DEFAULT_INSTALLED_PACKAGES_DIR under process.cwd()), and a per-boot OS_HOME / OS_STORAGE_LOCAL_ROOT does not redirect it, so an install from a boot started in the shared tree lands in examples/app-showcase/.objectstack/installed-packages (QA run #23163, 25 s, removed through the API). No clause changed", "ref": "#23169" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "steps[4] and [5] and acceptance[3].clause corrected (checklist-accuracy finding of QA run #23939, wave #23878). storageDir is served once, on the listing envelope (data.storageDir), not on each entry — the admin entry carries installedBy only — so 'installedBy + storageDir on each entry' named a field the entries never carry. The clause now reads each field where it lives and still requires both to be omitted for the member. Not loosened: a member who sees either field, or is refused or served an empty list, still FAILS", + "ref": "#24036" } ] }, @@ -1889,7 +1929,7 @@ "title": "Marketplace console honesty: nav entries live and die with their capability, the disabled state renders the configuration conclusion (never a red 'failed to load') for every viewer ahead of the admin guard, Installed Apps drives the real same-origin lifecycle, and an enabled runtime turns a non-admin away with access-denied", "since": "v17", "status": "active", - "revision": 4, + "revision": 5, "priority": "P2", "surface": "browser", "personas": [ @@ -1910,7 +1950,7 @@ "steps": [ "boot B (OS_CLOUD_URL=off), as admin in Setup: confirm the group_apps nav carries Installed Apps (nav_marketplace_installed — the entry rides MARKETPLACE_INSTALLED_UI_BUNDLE, registered by the install-local plugin that IS mounted) and does NOT carry Browse Marketplace (nav_marketplace_browse rides the browse proxy, which is not mounted) — the marketplace-ui.ts ownership rule: 'the entry lives and dies with the capability → no dead page'", "boot B: navigate directly to /apps/setup/system/marketplace — the page renders the MarketplaceDisabled card (data-testid marketplace-disabled): muted informational styling, a hint addressed to the operator naming the action, a Back-home button. Screenshot", - "boot B: open the Installed Apps page — the installed CRM entry renders with its manifest identity; drive reseed and purge from the page and capture the same-origin POSTs to /api/v1/marketplace/install-local/:manifestId/{reseed,purge}-sample-data on the wire", + "boot B: open the Installed Apps page — the installed CRM entry renders with its manifest identity; open that package's Details page, where reseed and purge live in the 'More install options' menu (objectui#11627 — they are not on the Installed Apps list itself), drive both and capture the same-origin POSTs to /api/v1/marketplace/install-local/:manifestId/{reseed,purge}-sample-data on the wire", "boot A (default), as admin: the group_apps nav now carries BOTH Browse Marketplace and Installed Apps plus Cloud Connection (nav_cloud_connection); open the browse page — if the control plane is reachable the catalog renders; if not, record blocked(environment) for the catalog leg only (the page mounting and asking is this kernel's part)", "as the plain member on boot A (marketplace enabled): navigate directly to /apps/setup/system/marketplace — once the admin verdict resolves (MarketplaceResolving holds only until then) the page renders MarketplaceAccessDenied, never an indefinite spinner and never the destructive card; then open a package URL (…/system/marketplace/:packageId) and confirm the package page issues no package request for the member. On boot B the same URLs render the MarketplaceDisabled card for the member too: objectui#5557 moved the disabled return AHEAD of the !isAdmin guard, because 'there is no marketplace here' is true of every viewer", "on boot B reload the disabled page several times: confirm no transient red 'Failed to load' card paints before the disabled card settles (the objectui#5533/#5557 resolving-state work — MarketplaceResolving covers the gap)" @@ -1929,9 +1969,9 @@ "evidence": "the screenshot + the runtime-config body it rests on" }, { - "clause": "Installed Apps is a real console: it lists the locally installed entry and its reseed/purge affordances issue the same-origin install-local POSTs, whose effects the api-lane item verifies — capture the wire calls here, score the row-level effects there (no double-cover)", + "clause": "Installed Apps is a real console: it lists the locally installed entry, and the reseed/purge affordances on that package's Details page ('More install options') issue the same-origin install-local POSTs, whose effects the api-lane item verifies — capture the wire calls here, score the row-level effects there (no double-cover)", "oracle": "network", - "verify": "the captured POSTs to /api/v1/marketplace/install-local/:manifestId/reseed-sample-data and purge-sample-data from the page's buttons, answering 2xx as admin", + "verify": "the captured POSTs to /api/v1/marketplace/install-local/:manifestId/reseed-sample-data and purge-sample-data from the Details page's 'More install options' menu, answering 2xx as admin", "evidence": "the network trace + the page screenshot" }, { @@ -1985,6 +2025,12 @@ "date": "2026-10-13", "change": "fixtures.requires[2] (new) carries the scratch-copy rule for any boot that installs a package (setup recipe gap, #23104; parked on #23121, carried by #23169). install-local caches its manifest under the working directory's .objectstack/installed-packages (local-manifest-source.ts DEFAULT_INSTALLED_PACKAGES_DIR, resolved against process.cwd()), and per-boot OS_HOME / OS_STORAGE_LOCAL_ROOT do not redirect it. In QA run #23163 a boot started from the shared tree wrote a CRM manifest into examples/app-showcase/.objectstack/installed-packages for 25 s before it was removed through the API. RUNNER.md's environment facts carry the same rule. No clause changed", "ref": "#23169" + }, + { + "revision": 5, + "date": "2026-10-16", + "change": "steps[2] and acceptance[2] (clause, verify) re-pointed (checklist-accuracy finding of QA run #23939, wave #23878). Reseed and purge are not on the Installed Apps list: they are in the package Details page's 'More install options' menu (objectui#11627), which is where #23939 drove them. The Installed Apps note #23939 saw as stale was objectui#12684; the console pin since moved that note into the Uninstall tooltip (objectui#12610). Not loosened: both POSTs must still leave the page same-origin and answer 2xx", + "ref": "#24036" } ] }, @@ -2255,7 +2301,7 @@ "title": "Keyboard shortcut surface: `?` and ?shortcuts=1 open the dialog and Esc closes it, typing in an input never triggers it, the dialog lists exactly the shortcuts whose handler is mounted and every listed one fires (⌘K, ⌘/Ctrl+B shown as ⌘B, Esc, ?), the five formerly advertised dead accelerators (⌘/, N, R, ⌘E, ⌘D) are neither listed nor bound, and the AI-chat pair ⌘⇧O / ⌘⇧S is listed only on the page that handles it (objectui#11674)", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "browser", "personas": ["seeded admin (admin@objectos.ai / admin123)"], @@ -2273,7 +2319,7 @@ "press `?` on a settled console page (focus NOT in an input) — the shortcuts dialog opens (overlay data-testid overlay:keyboard-shortcuts) and the URL carries ?shortcuts=1; press Esc — it closes and the param clears", "deep-link: open ?shortcuts=1 cold — the dialog is open on load (URL-addressable, ADR-0054)", "input guard: focus a text input (e.g. a list filter), type `?` — the character lands in the input and NO dialog opens (the handler returns early for INPUT/TEXTAREA/contentEditable)", - "inventory the dialog: capture every listed accelerator. The rows are derived from the handlers mounted at that moment (advertisedShortcuts.ts, objectui#11674), so on a stock page at objectui 1fb1ced36243 the dialog lists exactly: General — Close dialog/panel `Esc`, Open command palette `⌘ K`, Show keyboard shortcuts `?`; Navigation — Toggle sidebar `⌘ B`. No AI-chat group appears off the chat page, and nothing else is listed", + "inventory the dialog: capture every listed accelerator. The rows are derived from the handlers mounted at that moment (advertisedShortcuts.ts, objectui#11674), so on a stock page at the console pin the dialog lists exactly (read at objectui 1fb1ced36243, and again at daeb77fc84dd by QA run #23939): General — Close dialog/panel `Esc`, Open command palette `⌘ K`, Show keyboard shortcuts `?`; Navigation — Toggle sidebar `⌘ B`. No AI-chat group appears off the chat page, and nothing else is listed", "fire the WIRED set: ⌘K (or Ctrl+K) opens the command palette (depth of the palette itself belongs to search.console-global-search — here only that the accelerator fires); ⌘/Ctrl+B toggles the sidebar; ⌘⇧I is NOT probed here — its gated/entitled matrix belongs to ai.console-ai-surface-gating", "display-equals-binding probe for the sidebar: the dialog shows `⌘ B` (a mod chord), never bare B — press bare B (no modifier) outside any input: NOTHING happens (the handler requires metaKey||ctrlKey); press ⌘B and Ctrl+B: the sidebar toggles each time. Display and binding agree", "unlisted-key probes, one by one on the object list, focus on the page body: ⌘/ (once advertised as focus-search), N (create record), R (refresh data), ⌘E (edit record), ⌘D (toggle dark mode). First confirm none of the five is listed in the dialog; then, for each, capture that NO corresponding effect occurs (no focused search box, no create overlay, no refetch on the wire, no edit mode, no theme class change). None of the five has a handler, and since objectui#11674 none is advertised: the advertised set equals the wired set. A key here that now ACTS while unlisted, or one that is listed again with no effect, breaks that equality", @@ -2344,6 +2390,12 @@ "date": "2026-10-13", "change": "acceptance[2], [3] and [4] (#23104 C2, C3, C4) flipped from expected-fail probes to positive assertions, with the title, fixtures.requires[0], knownGaps[1], steps[3], [5] and [6], negative[2] and four source notes (stale spec; rows from #23104 parked on #23121, carried by #23169). objectui#11674 derived the shortcuts dialog's rows from the handlers mounted at that moment (advertisedShortcuts.ts), so the five dead accelerators (⌘/, N, R, ⌘E, ⌘D) are no longer listed, Toggle sidebar is listed as ⌘B rather than bare B, and the AI-chat pair ⌘⇧O / ⌘⇧S is advertised by AiChatPage and listed only while that page is mounted. QA run #23163 (objectstack 36b2889c, objectui 1fb1ced36243) read the stock inventory as General: Esc, ⌘K, ?; Navigation: ⌘B, with bare B inert, Ctrl+B toggling and each of the five keys without effect. Re-read from objectui source at the same pin, which is still this repo's .objectui-sha: KeyboardShortcutsDialog.tsx, advertisedShortcuts.ts, ConsoleLayout.tsx, AiChatPage.tsx and the pin KeyboardShortcutsDialog.wiredOnly-11674.test.tsx. The on-page half of acceptance[4] stays blocked(environment) on stock", "ref": "#23169" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "steps[3]'s pin note brought forward (checklist-accuracy finding of QA run #23939, wave #23878): the settled inventory — Esc, ⌘ K, ? and ⌘ B, nothing else off the chat page — matched exactly at objectui daeb77fc84dd too, not only at 1fb1ced36243. Wording only: the inventory and every clause are unchanged", + "ref": "#24036" } ] }, @@ -2441,27 +2493,27 @@ "title": "ADR-0057 lifecycle sweep: backdated telemetry rows past retention are reaped BOUNDED under a system context on the hourly sweep, onlyWhen scoping spares live rows, an archive-declaring object is NEVER hot-deleted unarchived (retained + reported archive-pending when no archive datasource exists), the transient class stays on the primary, and OS_LIFECYCLE_DISABLED=1 disarms the sweep", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": ["operator (local shell + direct sqlite access)", "seeded admin (API reads)"], "fixtures": { "app": "showcase", "requires": [ - "an isolated boot on a file DB (-d file:/tmp//data.db) WITH direct sqlite access between boots — retention judges created_at, which no REST write lets a caller backdate, so the crash-shaped fixtures are hand-seeded into the sqlite file (the same fixture class platform-core.interrupted-migration-boot-report proved)", + "an isolated boot on a file DB (-d file:/tmp//data.db) WITH direct sqlite access between boots — retention judges created_at, which no REST write lets a caller backdate, so the crash-shaped fixtures are hand-seeded into the sqlite files (the same fixture class platform-core.interrupted-migration-boot-report proved). ⚠ TWO files: on `os dev` with a file-backed primary the CLI provisions a sibling telemetry datasource, data.telemetry.db beside data.db (packages/cli/src/utils/telemetry-datasource.ts#resolveTelemetryDbPath), and the engine routes every telemetry/audit/event-classed object there — so sys_automation_run and sys_audit_log rows are seeded into data.telemetry.db, while the transient sys_inbox_message stays in data.db", "stock lifecycle declarations as the subjects: sys_automation_run (class telemetry, retention 30d, onlyWhen status ∈ completed|failed — the MIXED-table scoping), sys_audit_log (class audit, archive after 90d to the 'archive' datasource — the never-hot-delete subject), sys_inbox_message (class transient)", "patience for the timer: the first sweep fires DEFAULT_LIFECYCLE_INITIAL_DELAY_MS = 60s after engine start, then hourly — every probe boot needs a ≥60s settle before reading results (eventual-consistency: do not read the absence of a reap before the sweep ran)" ], "knownGaps": [ "the ARCHIVE-SUCCESS leg (rows copied to the archive datasource, then hot-deleted) needs a registered 'archive' datasource, which no stock boot has — that leg rides the unit pin (lifecycle-service.test.ts) and stays blocked(fixture) live; what IS live on stock is the SAFETY side: sys_audit_log rows older than 90d are RETAINED and the sweep reports the object skipped archive-pending", - "class-routing to a dedicated 'telemetry' datasource (ADR-0057 §3.6) engages only when a telemetry datasource is registered — absent on stock, so the routing leg is asserted at the unit/source level (engine.ts getDriver priority + SYSTEM_LEDGER_LIFECYCLE_CLASSES), not live", + "class-routing to a dedicated 'telemetry' datasource (ADR-0057 §3.6) engages when a telemetry datasource is registered — and the canonical checklist boot (`os dev` on a file DB) registers one: the CLI provisions .telemetry.db by default in dev (packages/cli/src/utils/telemetry-datasource.ts#resolveTelemetryDbPath; OS_TELEMETRY_DB=off opts out, and prod serve provisions none unless OS_TELEMETRY_DB names a path). So the routing is live on stock: sys_automation_run and sys_audit_log rows live in data.telemetry.db (QA run #23939 seeded them there). The engine side stays engine.ts getDriver priority + SYSTEM_LEDGER_LIFECYCLE_CLASSES", "the ≤ REAP_BATCH_SIZE × REAP_MAX_BATCHES_PER_SWEEP bound (500×20 per object per sweep) is proven by the unit suite; staging >10k backdated rows live is not required — the live clause asserts the reap happened via the bounded per-id path (the aggregate log line), not the bound's edge", "RULED, no longer a gap — the idle-proof step, steps[4] (QA run #23166 C1, parked on #23121, carried by #23169): the maintainer ruled (decision 6147446653, ruling A recorded in 6160866003) that the step was stale and the product is correct, and revision 3 rewrites steps[4] as a positive heartbeat check. The line the run saw is the design: an idle sweep on a clean DB logs '[lifecycle] sweep: 18 policy(ies) applied, ~0 rows reaped, …' (objectstack 36b2889c), because the print gate in lifecycle-service.ts tests report.swept.length and the reaper pushes a report.swept entry for every policy it applies, deleted 0 included. ADR-0057 §3.3 bounds that trace ('Aggregate one summary row at most'); the one-line-per-sweep wording is the code comment at the print site, not ADR text. A run scored against revision 2 or earlier that saw this line on an idle sweep observed correct behaviour, not a fail" ] }, "steps": [ "boot once on the file DB so schema exists; stop the server", - "hand-seed via sqlite: (a) N sys_automation_run rows with created_at 40 days ago, status 'completed' (past the 30d window, terminal); (b) 2 rows created_at 40 days ago with status 'paused'/'running' (past the window but NOT terminal); (c) 1 sys_audit_log row created_at 100 days ago (past archive.after 90d); (d) 1 sys_inbox_message row (transient class). Record exact counts and ids", + "hand-seed via sqlite, each row into the file its object lives in (fixtures.requires[0]): into data.telemetry.db — (a) N sys_automation_run rows with created_at 40 days ago, status 'completed' (past the 30d window, terminal); (b) 2 rows created_at 40 days ago with status 'paused'/'running' (past the window but NOT terminal); (c) 1 sys_audit_log row created_at 100 days ago (past archive.after 90d); into data.db — (d) 1 sys_inbox_message row (transient class). Record exact counts and ids. A row seeded into the wrong file is invisible to the sweep and reads as 'survived', which is the false pass this split exists to prevent", "boot again (control boot, no lifecycle flag) with OS_LOG_LEVEL=info exported (or `--log-level info`): the aggregate sweep line is logged at INFO, and `os dev` defaults the kernel logger to warn, so a default-level boot never prints it; wait ≥90s; grep the log for '[lifecycle] sweep: … policy(ies) applied, ~… rows reaped …' (the ONE trace a sweep leaves — cleanup must not re-feed the tables it drains)", "re-read via API/sqlite: the terminal 40d-old automation rows are GONE; the paused/running 40d-old rows SURVIVE (onlyWhen scoping); the 100d-old audit row SURVIVES and the sweep report lists sys_audit_log skipped archive-pending (no 'archive' datasource registered → retained, never dropped unarchived); the inbox row's fate follows its own declared policy only — the transient class got no cross-class treatment", "idle heartbeat (ruled on #23169: an idle sweep still logs its line): let one more sweep run on the now-clean DB — keep steps[2]'s boot up for one more sweep interval (hourly, DEFAULT_LIFECYCLE_SWEEP_MS), or boot fresh on the same file DB with OS_LOG_LEVEL=info exported as in steps[2] and wait ≥90s. That sweep still logs its ONE aggregate INFO line, '[lifecycle] sweep: N policy(ies) applied, ~0 rows reaped, …': the line prints whenever a policy was applied, and every applied policy counts, deleted 0 included, so N counts (object, policy) pairs, not rows, and does not fall to 0 when nothing is due (with 0 error(s) it equals steps[2]'s N on the same composition; the archive-pending sys_audit_log is reported skipped and never counted). Record the line verbatim, then re-read: the rows steps[3] saw reaped stay gone and its survivors still survive. A missing line on an info-level boot means no sweep reached its print (not armed, disabled in settings, or aborted — e.g. the '[lifecycle] governance snapshot could not be loaded' warn), never 'idle'; a default warn-level boot never prints the line at all, so it proves nothing here", @@ -2556,6 +2608,12 @@ "date": "2026-10-15", "change": "the idle-proof ruling is applied: decision 6147446653, answered 「23169 我同意。」 and recorded as ruling A in 6160866003 (the checklist step was stale; the product is correct). steps[4] is rewritten from 'an idle sweep logs nothing' to a positive heartbeat: one more sweep on the now-clean DB, at OS_LOG_LEVEL=info as in steps[2], still logs its one aggregate line with ~0 rows reaped, and the reaped rows stay gone. Grounds, re-read at objectstack c5dd7fce3: the print gate in packages/objectql/src/lifecycle/lifecycle-service.ts tests report.swept.length (or errors, or alerts), and reap() pushes a report.swept entry for every applied policy, deleted 0 included (a deleted: 0 entry is pinned in lifecycle-service.no-tenant-column.test.ts); an archive-pending object goes to report.skipped instead. Attribution, corrected from the ruling record: 'one aggregate log line per sweep is the entire trace it leaves' is the code comment at that print site, while ADR-0057 §3.3's own text is that the service's deletes and rotations are not audited or activity-logged, and 'Aggregate one summary row at most'. fixtures.knownGaps[3], which recorded the owed ruling, now reads RULED and keeps the reason, so a run record scored against an earlier revision stays interpretable. steps[5] (the kill switch, still bracketed by steps[2]'s armed control), every acceptance clause, negative, variants and traps are unchanged: no clause rested on steps[4] before, and none does now. No product change", "ref": "#23169" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "fixtures.requires[0], knownGaps[1] and steps[1] re-pointed (checklist-accuracy finding of QA run #23939, wave #23878). The knownGap said no telemetry datasource exists on stock, but `os dev` on a file DB now provisions data.telemetry.db beside the primary and routes telemetry/audit/event-classed objects to it — sys_automation_run and sys_audit_log live there, which is where #23939 seeded them. The seeding step now names the file per object; a row seeded into the primary would sit outside the sweep and read as a survivor. Not loosened: the clauses score the same reap, survivors and archive-pending report", + "ref": "#24036" } ] }, @@ -2564,14 +2622,14 @@ "title": "Seed mode matrix (insert/update/upsert/replace/ignore): each mode does exactly its verb through the ONE SeedLoaderService, no-op replays skip instead of churning, ignore reports skips — and `replace` is DECLARED delete-all-then-insert but IMPLEMENTED as bare insert (no caller clears the table): the posture is recorded, never ticked as scoped deletion", "since": "v15", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": ["seeded admin (admin@objectos.ai / admin123)"], "fixtures": { "app": "showcase", "requires": [ - "a runtime-created WRITABLE package (the metadata-authoring-roundtrip fixture) — `seed` is allowRuntimeCreate, so per-mode scratch seeds are authored as drafts (PUT /api/v1/meta/seed/ with an explicit `mode`) and applied by publish (applySeedBodies materializes published seed bodies through the SAME SeedLoaderService the boot path uses — packages/runtime/src/seed-loader.ts is a re-export shim of it)", + "a runtime-created WRITABLE package (the metadata-authoring-roundtrip fixture) — `seed` is allowRuntimeCreate, so per-mode scratch seeds are authored as drafts (PUT /api/v1/meta/seed/ with an explicit `mode`) and applied by publish (applySeedBodies materializes published seed bodies through the SAME SeedLoaderService the boot path uses — packages/runtime/src/seed-loader.ts is a re-export shim of it). The seed BODY carries object, mode, records (and externalId when not the default) and NO `name` key — the URL names the seed, and SeedSchema is strict (packages/spec/src/data/seed.zod.ts#SeedSchema), so a body `name` answers 422 unrecognized_keys", "a scratch target object in the same writable package (do NOT aim mode probes, least of all replace/insert replays, at seeded showcase objects — destructive-in-place)" ], "knownGaps": [ @@ -2580,13 +2638,13 @@ ] }, "steps": [ - "author the scratch object (2 fields + a natural-key `name`) into the writable package and publish; note `externalId` defaults to 'name' and `defaultMode` to 'upsert' (SeedLoaderRequestSchema)", + "author the scratch object (2 fields + a natural-key `name`, and a declared `sharingModel` — a draft without one is accepted, but its publish is refused (publish-drafts answers outcome 'refused', failed[0] code INVALID_METADATA, rule security-owd-unset) and so is an active save (422 INVALID_METADATA, rule security-owd-unset)) into the writable package and publish; note `externalId` defaults to 'name' and `defaultMode` to 'upsert' (SeedLoaderRequestSchema)", "UPSERT: publish a seed draft (mode upsert) with rows A,B → both insert; edit the draft changing B's value and adding C; re-publish → A skipped (no-op replay — only seed-declared fields compared, so runtime edits to OTHER columns never block the skip), B updated in place (same id), C inserted; verify by id stability + values", "UPDATE: seed draft (mode update) touching A (changed value) and a nonexistent D → A updates, D is SKIPPED (update never creates); row count unchanged", "IGNORE: seed draft (mode ignore) with A (changed value) and new E → A untouched (still the update-mode value; existing rows are never rewritten), E inserted; the skip is visible as A's unchanged value + count", "INSERT: seed draft (mode insert) re-declaring A → a SECOND A-named row appears (no matching in insert mode); record the duplication; if the object declares a unique key on the natural key instead, record the refusal — either shape is the mode's real contract", "REPLACE (expected-posture probe): add one row F by hand through /api/v1/data (a row the seed does NOT declare); publish a seed draft (mode replace) declaring only G → capture what happens to F. At head: F SURVIVES and G is inserted — the loader's replace case is bare insert with the comment 'caller should have cleared the table', and NO caller in the tree clears anything. Record the declared-vs-implemented gap; do NOT tick 'replace scoped to its own object' (there is no deletion to scope) and do NOT file F's survival as data loss (it is the opposite)", - "idempotence tie-in: re-publish the upsert draft unchanged → zero updates (all no-op skips), updated_at values untouched (the churn-avoidance isNoOpReplay exists for exactly this)", + "idempotence tie-in: re-publish the upsert draft unchanged TWICE. The first replay may legitimately write, because steps 2–4 rewrote A's rows (update mode set A to a2, insert mode added a second A-named row), so it re-converges them to the seed; the SECOND replay is the idempotence probe — zero inserts and zero updates (all no-op skips), every row's updated_at untouched (the churn-avoidance isNoOpReplay exists for exactly this)", "teardown: the writable package's own delete, or discard the isolated DB" ], "acceptance": [ @@ -2617,7 +2675,7 @@ { "clause": "EXPECTED POSTURE (declared ≠ implemented): `replace` is documented 'Delete ALL records in object then insert (Dangerous)' but the implementation is bare insert — writeRecord's replace arm inserts with the comment 'caller should have cleared the table' and NO caller anywhere clears it, so pre-existing rows SURVIVE a replace-mode publish. The run records this gap (a checklist-accuracy/product finding routed through the sweep's anchor, not re-filed per run); it must NOT tick a 'replace deletes only its own object's rows' claim — no deletion exists to scope. If a future head implements the clear, this clause is stale: revise, and then ALSO prove the deletion is scoped to the seed's own object before trusting it", "oracle": "api", - "verify": "F (undeclared, hand-inserted) still present after the replace publish; G inserted; grep evidence: the only 'replace' write arm is the insert at seed-loader.ts and decideWriteAction:2106 routes replace to insert", + "verify": "F (undeclared, hand-inserted) still present after the replace publish; G inserted; grep evidence: the only 'replace' write arm is the insert in seed-loader.ts's writeRecord, and decideWriteAction (the batched mirror) routes replace to insert — cite the symbols, never a line number", "evidence": "F's surviving read + G's read + the source excerpt" }, { @@ -2661,6 +2719,12 @@ "date": "2026-08-30", "change": "new — authored in the 2026-08-30 sweep (spec-enums angle), with the register's central claim CORRECTED against source: the register said 'replace = clear-then-insert (decideWriteAction)', but at head NOTHING clears — writeRecord's replace arm is a bare insert whose own comment defers to a caller that does not exist (grepped: no clearTable/deleteMany/delete caller anywhere on the seed path), so pre-existing rows survive a replace publish. The clause is therefore an expected-posture record of the declared≠implemented gap, not a scoped-deletion assertion. Also recorded honestly: insert-mode 'fail on duplicate' rides the object's own constraints (the loader does no matching in insert mode)", "ref": "#sweep-2026-08-30" + }, + { + "revision": 2, + "date": "2026-10-16", + "change": "fixtures.requires[0], steps[0] and [6] and acceptance[4].verify corrected (checklist-accuracy findings of QA run #23939, wave #23878; steps[6] from this card's re-run). A seed body refuses a `name` key (SeedSchema is strict; the URL names the seed) and a scratch object needs a sharingModel (its publish is refused, rule security-owd-unset), so the fixture as written could not be authored; steps[6]'s replay ran after steps 2–4 had rewritten A's rows, so its first replay legitimately writes — the step now scores the second replay, which the re-run measured at 0 inserted / 0 updated; and the verify text pinned decideWriteAction to a source line that has since moved — it now cites the symbols only. Not loosened: every mode clause and the replace posture are scored as before", + "ref": "#24036" } ] }, @@ -2805,7 +2869,7 @@ "title": "The settings audit trail keeps no fingerprint of a secret-valued setting that a reader can match offline: both ledgers record the platform's keyed digest or none, never the value or an unkeyed hash, so a caller who can only read the audit rows learns that the secret changed and nothing about its value", "since": "v17", "status": "active", - "revision": 1, + "revision": 2, "priority": "P1", "surface": "api", "personas": [ @@ -2827,11 +2891,11 @@ "steps": [ "generate a fresh random value V1 for this run (never a value used anywhere else). As admin, PUT /api/settings/mail { provider: 'resend', api_key: V1, from_email: 'ops@example.com' }", "read the per-key ledger: GET /api/v1/data/sys_setting_audit filtered to namespace=mail and key=api_key; capture the newest row (action set, encrypted true, new_hash)", - "read the generic ledger: GET /api/v1/data/sys_audit_log filtered to action=config_change; capture the newest row for mail.api_key (object_name 'sys_setting'; new_value is a JSON string carrying namespace, key, scope and digest; metadata carries encrypted: true)", - "OFFLINE, with no server key: compute the SHA-256 hex of V1 (bare, and with a 'sha256:' prefix) and search both captured rows, every field, for it and for V1 itself", + "read the generic ledger: GET /api/v1/data/sys_audit_log filtered to action=config_change; capture the newest row for mail.api_key (object_name 'sys_platform_setting' — mail is a GLOBAL-scope namespace, and a global-scope value's row names the tenant-less table it lives in, packages/services/service-settings/src/config-change-audit.ts#CONFIG_CHANGE_GLOBAL_OBJECT_NAME; a tenant-scope key's row names 'sys_setting'; new_value is a JSON string carrying namespace, key, scope and digest; metadata carries encrypted: true)", + "OFFLINE, with no server key: compute the unkeyed digests a leak would carry — the FNV-1a 32 of V1's canonical JSON (the quoted string), spelled 'fnv32:' + 8 lowercase hex, which is the unkeyed digest this settings service writes for a non-secret key (packages/services/service-settings/src/crypto-adapter.ts#digest), and, belt and braces, the SHA-256 hex of V1 (bare, and with a 'sha256:' prefix) — and search both captured rows, every field, for each of them and for V1 itself", "write V1 again, then a different value V2, then V1 a third time; re-read both ledgers and compare the fingerprints of the three writes", "reset the key: PUT /api/settings/mail { provider: 'log', api_key: null } (a null value is the reset; 'log' needs no api_key), then read the reset rows", - "control: write the NON-secret key mail.from_email and compute OFFLINE the SHA-256 of its canonical JSON (the quoted string). Its rows must carry that unkeyed digest. This proves step 4's offline search WOULD have found an unkeyed digest, and that the keyed rule is scoped to secret-valued keys" + "control: write the NON-secret key mail.from_email and compute OFFLINE the FNV-1a 32 of its canonical JSON (the quoted string), spelled 'fnv32:' + 8 lowercase hex. Its rows must carry exactly that unkeyed digest. This proves step 4's offline search — which computes the same algorithm — WOULD have found an unkeyed digest, and that the keyed rule is scoped to secret-valued keys" ], "acceptance": [ { @@ -2841,7 +2905,7 @@ "evidence": "both captured rows, quoted in full" }, { - "clause": "a reader of the audit rows who holds no server key cannot confirm a guess: no fingerprint in either row can be reproduced from the value alone. The offline SHA-256 of V1, with or without the sha256 prefix, matches nothing in either row", + "clause": "a reader of the audit rows who holds no server key cannot confirm a guess: no fingerprint in either row can be reproduced from the value alone. No offline unkeyed digest of V1 — the FNV-1a 32 of its canonical JSON ('fnv32:…', the algorithm the service uses for non-secret keys), nor the SHA-256 of V1 with or without the sha256 prefix — matches anything in either row", "oracle": "api", "verify": "the step 4 offline search over both rows finds no hit, AND the step 7 control's offline digest DOES match the non-secret row. Without the control, a no-hit proves nothing about the search", "evidence": "the offline digest values, the two secret rows, and the matching control row" @@ -2861,7 +2925,7 @@ { "clause": "non-secret settings are unchanged: a non-encrypted key keeps the unkeyed digest of its canonical JSON on both ledgers. The keyed rule applies to secret-valued keys only", "oracle": "api", - "verify": "mail.from_email's rows carry the SHA-256 of the quoted JSON string, computed offline in step 7", + "verify": "mail.from_email's rows carry the FNV-1a 32 digest of the quoted JSON string ('fnv32:' + 8 lowercase hex), computed offline in step 7", "evidence": "the control rows and the offline digest" }, { @@ -2872,7 +2936,7 @@ } ], "negative": [ - "an unkeyed digest of a secret-valued setting (a plain SHA-256 of the value, with or without a prefix) on EITHER ledger is a FAIL. Anyone who can read the audit rows could confirm a guessed value offline, and settings secrets are short, guessable values", + "an unkeyed digest of a secret-valued setting (the 'fnv32:' FNV-1a 32 of its canonical JSON, or a plain SHA-256 of the value, with or without a prefix) on EITHER ledger is a FAIL. Anyone who can read the audit rows could confirm a guessed value offline, and settings secrets are short, guessable values", "the plaintext of a secret-valued setting in any field of either ledger is a FAIL", "a host with no keyed digest falling back to an unkeyed one is a FAIL; the rule is to record no fingerprint", "a secret write REFUSED because its audit fingerprint could not be computed is a FAIL: the audit trail never vetoes a settings save", @@ -2899,6 +2963,12 @@ "date": "2026-10-06", "change": "new: the settings audit trail had no item. platform-core.settings-hub-roundtrip only asserts that an audit row exists, and #21792 was found outside its clauses. The rule PR #21809 landed under the #21792 ruling is now a stock-runnable item: both ledgers carry the keyed digest for a secret-valued setting, the offline check carries a positive control (the non-secret key's unkeyed digest IS found), equal/different values and the reset are asserted, and the no-keyed-digest arm is scored from the unit pin because a stock boot cannot reach it", "ref": "#21932" + }, + { + "revision": 2, + "date": "2026-10-16", + "change": "steps[2], [3] and [6], acceptance[1].clause and [4].verify and negative[0] corrected (checklist-accuracy finding of QA run #23939, wave #23878). The non-secret digest is FNV-1a 32 of canonical JSON ('fnv32:'), not SHA-256 (crypto-adapter.ts digest), so the step-7 control computed with SHA-256 never matched and could not prove the offline search would find an unkeyed digest. The control and the secret search now compute the same algorithm — FNV-1a 32 first, SHA-256 kept as a second net — and the generic-ledger row for global-scope mail names sys_platform_setting, not sys_setting. Not loosened: the search now covers the algorithm the service actually uses, so a leaked unkeyed digest is caught where the old search could miss it", + "ref": "#24036" } ] }, diff --git a/docs/qa/platform-checklist/areas/records-forms.json b/docs/qa/platform-checklist/areas/records-forms.json index 6d1da7e72b..9f67c3a733 100644 --- a/docs/qa/platform-checklist/areas/records-forms.json +++ b/docs/qa/platform-checklist/areas/records-forms.json @@ -66,7 +66,7 @@ "title": "Create → read → update → delete a record through the console UI", "since": "v15", "status": "active", - "revision": 9, + "revision": 10, "priority": "P0", "surface": "browser", "personas": [ @@ -148,7 +148,7 @@ } ], "negative": [ - "save with required status empty → the form blocks with a named field error AND no row is created (filtered API count stays 0) — a silent success is a FAIL", + "save with the required Account Name empty → the form blocks with a named field error AND no row is created (filtered API count stays 0) — a silent success is a FAIL. ⛔ Not the Lifecycle (status) select: it offers no empty option and defaults to Prospect, so a status-empty save cannot be staged in the form", "a clone that carries over the source's id / audit columns / an autonumber / a readonly approval_status instead of re-deriving them is a FAIL (#3043 — a clone must not mint an already-approved record); a clone of an RLS-invisible source that SUCCEEDS (200/201 with a new row) is a FAIL — the findOne runs in the caller's context precisely to refuse it", "a direct API POST missing required status on an object whose required field has NO default → 400 VALIDATION_FAILED with fields[] carrying code 'required' (server enforces, not just the form — packages/objectql/src/validation/record-validator.ts). ⛔ Do NOT drive this on showcase_account: its status is required:true but the 'prospect' option carries default:true (data/objects/account.object.ts), so the default satisfies the requirement and the row is created with status:'prospect' — a 201 here is CORRECT behaviour, not a missing server guard. Pick a required field with no defaulted option, or record the substitution", "a create with tax_id violating the tax_id_format rule → named validation error, never a silently-stored bad value" @@ -219,6 +219,12 @@ "date": "2026-10-06", "change": "partial-coverage gap of QA run #21782 parked on #22017 (row 2). acceptance[6]'s readonly half was blocked(fixture): no stock readonly column can be set over HTTP, so a carried value and a re-derived one read the same. Disposition: pin. protocol.readonly-insert.test.ts pins that clone forwards the copied readonly column to engine.insert and reports the drop, and engine-insert-static-readonly-strip.test.ts pins the engine's strip on a real engine. The clause now says to score that half from those pins, and `automated` names them for that half only", "ref": "#22017" + }, + { + "revision": 10, + "date": "2026-10-16", + "change": "negative[0] re-pointed (checklist-accuracy finding of QA run #23949, wave #23878): the Lifecycle select has no empty option and defaults to Prospect, so 'save with required status empty' could not be staged; the negative now empties the required Account Name, which the form can leave empty. Not loosened: the form must still block with a named error and create no row", + "ref": "#24036" } ] }, @@ -227,7 +233,7 @@ "title": "Field-type matrix: every FieldTypeSchema member renders its widget, accepts a valid value, round-trips over HTTP, and rejects invalid input", "since": "v15", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": [ @@ -298,7 +304,7 @@ "steps": [ "run the pinned HTTP round-trip matrix: pnpm --filter @objectstack/dogfood exec vitest run test/field-zoo-roundtrip.dogfood.test.ts (write vectors + expected read shapes live in test/field-zoo.matrix.ts)", "boot showcase isolated; open Field Zoo (/_console/apps/com.example.showcase/showcase_field_zoo) and open 'Specimen — Full' in edit mode; screenshot the form before reading DOM", - "enumerate the rendered control for every f_* field and build a variant→widget table (date→date input, color→color input, richtext→editor, select/multiselect→pickers, image/file→upload, code→code editor, location/address→structured inputs, rating/slider/progress→their own controls, autonumber/formula→read-only)", + "enumerate the rendered control for every f_* field and build a variant→widget table (date→date input, color→color input, richtext / markdown / html / code→their dedicated field widgets, select/multiselect→pickers, image/file→upload, location/address→structured inputs, rating/slider/progress→their own controls, autonumber/formula→read-only). At the console pin the richtext, markdown, html and code widgets are textarea-based BY DESIGN (objectui packages/fields/src/widgets/RichTextField.tsx / CodeField.tsx: a Textarea inside the dedicated widget, with its own label and controls): record that as the dedicated widget, not as a generic text input", "through the form, change one representative value per family (a select, an array type, a temporal, a structured JSON), Save, and capture the PATCH", "re-read the record via GET /api/v1/data/showcase_field_zoo/ and diff against the submitted values (arrays compared as sets)", "POST an out-of-set select value (f_select: 'not-a-value') directly to /api/v1/data/showcase_field_zoo and capture the refusal", @@ -313,7 +319,7 @@ "evidence": "test run output" }, { - "clause": "PER-VARIANT: each of the 49 FieldTypeSchema members renders its real widget on the Specimen — Full form (not a generic text input), with per-variant evidence recorded in the variant→widget table", + "clause": "PER-VARIANT: each of the 49 FieldTypeSchema members renders its real widget on the Specimen — Full form (its dedicated field widget, not the generic text input), with per-variant evidence recorded in the variant→widget table. For richtext, markdown, html and code the dedicated widget is textarea-based by design (RichTextField / CodeField) — that counts; the generic text input rendered for one of them does not", "oracle": "dom", "verify": "after the screenshot confirms render, match each f_* control against its declared type; every variant row in the table carries its own observed control + screenshot crop", "evidence": "form screenshot + the 49-row variant→widget table" @@ -382,6 +388,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": 3, + "date": "2026-10-16", + "change": "steps[2] and acceptance[1].clause say what counts for the textarea-based widgets (checklist-accuracy finding of QA run #23949, wave #23878). 'not a generic text input' with 'richtext→editor' read as a FAIL for richtext, markdown, html and code, whose dedicated widgets are textarea-based by design at the console pin (RichTextField / CodeField). The clause now scores the dedicated widget, and the generic text input still FAILS. Wording: no request or oracle changed", + "ref": "#24036" } ], "enumSource": { @@ -395,7 +407,7 @@ "title": "List-view capability matrix: filter, search, sort, pagination, saved views, inline edit, export, visualization switcher, bulk/row actions, conditional formatting", "since": "v15", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "browser", "personas": [ @@ -425,14 +437,14 @@ ], "steps": [ "open /_console/apps/com.example.showcase/showcase_task and screenshot the default All Tasks grid", - "switch to the in_progress saved view; capture the data request — expect $filter carrying the stored rule as an ObjectQL AST ([[\"status\",\"equals\",\"in_progress\"]]) and a 200; the count bar must read 2", - "quick-search 'huaning' on Accounts (or '张' on Contacts); capture the $search request and the hits (pinyin + CJK recall, ADR-0061)", + "switch to the in_progress saved view; capture the data request — expect `filter` carrying the stored rule as an ObjectQL AST ([[\"status\",\"equals\",\"in_progress\"]]) and a 200; the count bar must read 2. The console spells its list params WITHOUT a `$` — filter, top, skip, search, sort — the same AST under the bare name, so a literal grep for $filter finds nothing", + "quick-search 'huaning' on Accounts (or '张' on Contacts); capture the `search` request and the hits (pinyin + CJK recall, ADR-0061)", "open the tabular saved view (string-form sort 'estimate_hours desc' — the objectui#2601 fixture); confirm it renders and the request carries the sort", - "page a list past page 1; capture $top/$skip on every data request; confirm no unpaged full fetch", + "page a list past page 1. No stock list holds more rows than the 50-row page (the largest seeded task and contact lists are under it), so stage one first: POST /api/v1/data/showcase_inquiry/createMany with a bare JSON array of 55 rows (name + email each), then page the Inquiries list; capture `top`/`skip` on every data request; confirm no unpaged full fetch", "in the grid saved view (inlineEdit: true), edit one cell in place; save; capture the PATCH; re-read the row via API", - "on in_progress, with the export grant from fixtures.requires[3] bound, export each declared format (csv, xlsx, json); capture the requests and files. Without the grant the Export control is absent and the route answers 403 EXPORT_NOT_PERMITTED — the opt-in working as designed, not a fail of the export variant", + "on in_progress, with the export grant from fixtures.requires[3] bound, open the toolbar's Display popover — Export sits in its footer ('Export as CSV / XLSX / JSON'), not as a separate toolbar button — and export each declared format (csv, xlsx, json); capture the requests and files. Without the grant the Export entries are absent and the route answers 403 EXPORT_NOT_PERMITTED — the opt-in working as designed, not a fail of the export variant", "open the visualization switcher on All Tasks; enumerate the offered types; switch through each", - "on bulk_actions, select rows and run showcase_mark_done (per-record) and showcase_recalc_selection (aggregate — ONE dispatch carrying params._selectedIds); on legacy_row_actions confirm both named row actions resolve to live menu entries", + "on bulk_actions, select rows and run showcase_mark_done (per-record) and showcase_recalc_selection (aggregate — ONE dispatch: an api-type action, it POSTs to its own endpoint /api/v1/apps/showcase/recalc with `_selectedIds` at the body's top level); on legacy_row_actions confirm both named row actions resolve to live menu entries", "on Field Zoo, confirm the conditionalFormatting row highlight applies to Specimen — Full (f_lookup set) and not Specimen — Minimal" ], "acceptance": [ @@ -443,21 +455,21 @@ "evidence": "per-variant trace/screenshot set" }, { - "clause": "a saved view's stored filter reaches $filter as an ObjectQL AST, the server answers 200, and the filtered count is server-computed (2 of 10 for in_progress)", + "clause": "a saved view's stored filter reaches the `filter` param as an ObjectQL AST, the server answers 200, and the filtered count is server-computed (2 of 10 for in_progress)", "oracle": "network", - "verify": "captured GET /api/v1/data/showcase_task?...$filter=[[\"status\",\"equals\",\"in_progress\"]] → 200; count bar matches the response total", + "verify": "captured GET /api/v1/data/showcase_task?...filter=[[\"status\",\"equals\",\"in_progress\"]] → 200; count bar matches the response total", "evidence": "request/response trace + screenshot" }, { - "clause": "quick-search issues a server-side $search (not client filtering) and finds the CJK account via pinyin ('huaning' → 华宁科技)", + "clause": "quick-search issues a server-side `search` (not client filtering) and finds the CJK account via pinyin ('huaning' → 华宁科技)", "oracle": "api", - "verify": "the captured request carries $search; the response contains the CJK row; the same query direct against GET /api/v1/data/showcase_account reproduces it", + "verify": "the captured request carries `search`; the response contains the CJK row; the same query direct against GET /api/v1/data/showcase_account reproduces it", "evidence": "request trace + response JSON" }, { - "clause": "paging issues $top/$skip requests; the full set is never fetched; a walked page sequence visits every row exactly once (deterministic paging)", + "clause": "paging issues `top`/`skip` requests; the full set is never fetched; a walked page sequence visits every row exactly once (deterministic paging)", "oracle": "network", - "verify": "page 1 = $top=N; page 2 = $top=N&$skip=N; union of pages has no duplicate and no missing id", + "verify": "page 1 = top=N; page 2 = top=N&skip=N; union of pages has no duplicate and no missing id", "evidence": "the request URLs + the id-union check" }, { @@ -475,7 +487,7 @@ { "clause": "export offers exactly the declared formats (csv, xlsx, json on in_progress) and the exported rows equal the FILTERED set, not the whole table", "oracle": "network", - "verify": "with the fixtures.requires[3] export grant bound (export is opt-in): the export menu lists the three exportOptions; each downloaded file contains the 2 in_progress rows. Record which grant the run provisioned", + "verify": "with the fixtures.requires[3] export grant bound (export is opt-in): the Display popover's Export footer lists the three exportOptions; each downloaded file contains the 2 in_progress rows. Record which grant the run provisioned", "evidence": "menu screenshot + the three files" }, { @@ -485,7 +497,7 @@ "evidence": "dropdown screenshot + one screenshot per visualization" }, { - "clause": "bulk actions dispatch correctly by mode: per-record actions issue one dispatch per selected record; the aggregate def (showcase_recalc_selection, execution: 'aggregate') issues ONE request carrying every selected id in params._selectedIds", + "clause": "bulk actions dispatch correctly by mode: per-record actions issue one dispatch per selected record; the aggregate def (showcase_recalc_selection, execution: 'aggregate', an api-type action) issues ONE request — POST /api/v1/apps/showcase/recalc — carrying every selected id in `_selectedIds` at the body's top level", "oracle": "network", "verify": "count the captured POSTs against the selection size for each mode", "evidence": "network trace of both runs" @@ -530,6 +542,12 @@ "date": "2026-10-13", "change": "fixtures.requires[3] (new) provisions an export grant, and steps[6] and acceptance[6].verify name it (fixture gap, #23163 A6; parked on #23121, carried by #23169). Export is opt-in: resolveUserExportAllowed allows it only where a set says allowExport: true, and no stock permission set does, the admin wildcard included (#3544). So as the seeded admin Export is hidden and the export route answers 403 EXPORT_NOT_PERMITTED. QA run #23163 scored A6 through a runtime set granting showcase_task allowRead + allowExport, bound through sys_user_permission_set by the set's NAME. The recipe uses the spec's PermissionSetSchema key `objects` at the metadata door (the stored sys_permission_set column is object_permissions). No clause's verdict logic changed", "ref": "#23169" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "steps[1], [2], [4], [6] and [8] and acceptance[1], [2], [3], [6] and [8] re-pointed (checklist-accuracy findings of QA run #23949, wave #23878). The console sends its list params without a `$` (filter / top / skip / search / sort — the same AST), so every $-spelled expectation named a param no request carries; the aggregate bulk action is api-typed and POSTs `_selectedIds` at the body top level to /api/v1/apps/showcase/recalc, not params._selectedIds to /actions; no stock list exceeds the 50-row page, so the paging step now stages 55 inquiries through createMany (as #23949 did); and Export sits in the Display popover's footer. Not loosened: every clause scores the same server requests and counts", + "ref": "#24036" } ] }, @@ -858,7 +876,7 @@ "title": "Grid-level conditional rules: row-scoped requiredWhen and parent-scoped readonlyWhen in the inline line-item grid", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "browser", "personas": [ @@ -887,7 +905,7 @@ { "clause": "row-scoped requiredWhen flags the cell per row: only the row crossing quantity >= 100 is marked, and the batch save is blocked with a named cell error while its description is empty", "oracle": "dom", - "verify": "after screenshot, assert the required flag on row 1 and its absence on row 2; blocked save shows the error (pinned by objectui e2e/live/grid-conditional-rules.spec.ts)", + "verify": "after screenshot, assert the required flag on row 1 and its absence on row 2; the blocked save shows the named cell error and NOTHING is written (pinned for the inline flag by objectui e2e/live/grid-conditional-rules.spec.ts). Note for the reader: the client still sends the one batch, and the server refuses it 400 (description / required) — that send is by design (recheck 6218243967 item 13), so score the refusal and the unchanged rows, never the absence of a request; the objectui pin asserts only the inline required flag", "evidence": "screenshots + blocked save" }, { @@ -950,6 +968,12 @@ "date": "2026-10-05", "change": "clause 5, step 3 and the invoice fixture line re-paired (invalid pairing, #21782 VF3; VF3 gave no verbatim wording, so the clause is derived). On stock INV-1001 (positions 0 and 1), editing one line sends only that line, and the sibling stays byte-identical (masterDetailTx changedFields). The position re-emit happens only when sibling lines share a stored position, because the grid stamps row[sortField] = index on every change (GridField). INV-1002 ships one line, and a second line added through the API defaults to position 0, so the run's collision was created by the fixture. The clause now names an invoice whose lines carry distinct positions, and the fixture says how to seed one. Re-read at objectui 9dfaca6543: the mechanism is unchanged. The grid's config key moved from sort_field to sortField at both ends in the #21772 pin range (GridField + MasterDetailForm), so the source cites the new spelling", "ref": "#21797" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "acceptance[0].verify records what 'blocked' means on this surface (the #23949 note for the next author): a client-blocked grid save still sends one batch, which the server refuses 400, by design per recheck 6218243967 item 13, and the objectui pin asserts only the inline flag. The verify text now scores the refusal and the unchanged rows. Wording: the clause and its oracle are unchanged", + "ref": "#24036" } ] }, @@ -1566,7 +1590,7 @@ "title": "Action buttons surface at exactly their declared locations — list toolbar, list row, record header/more/related/section — and each dispatches for real", "since": "v15", "status": "active", - "revision": 10, + "revision": 11, "priority": "P1", "surface": "browser", "personas": [ @@ -1597,7 +1621,7 @@ "boot the showcase isolated; sign in as admin; open the showcase_task list view", "for each location variant, navigate to its surface (list header / row menu / a not-done task's Task Detail title bar / that title bar's ⋯ menu / the rows of the Tasks related list on a project's Project Detail Related tab, not the Tasks tab's line-items grid / the Task Detail quick-actions bar; fixtures.knownGaps names each record host), screenshot AFTER render settles, then read the rendered action buttons from the DOM", "record for every fixture action WHERE it rendered — building the full placement matrix (rendered locations vs declared locations)", - "dispatch one action per location with a ref-targeted click: bulk_reassign (screen-flow wizard opens), quick_view (modal opens), mark_done (script executes), log_time (form dialog opens on showcase_task.form), open_docs (url navigation), recalc_selection from the ⋯ menu (api POST); capture each network trace", + "dispatch one action per location with a ref-targeted click: bulk_reassign from the list toolbar — select a row FIRST: with none selected it toasts 'This action runs on a single record — select a row first' and dispatches nothing — (screen-flow wizard opens), quick_view (modal opens), mark_done (script executes), log_time (form dialog opens on showcase_task.form), open_docs (url navigation), recalc_selection from the ⋯ menu (api POST); capture each network trace", "verify the state-changing dispatches server-side: mark_done flips the task's done flag (API re-read), recalc_selection's per-record branch recomputes the estimate", "CEL visibility both sides: locate a done task and a not-done task; read the row menu and record header of each for showcase_mark_done", "empty-locations probe, both halves: (a) only where a PACKAGED action with NO locations key exists (none ships on stock — knownGaps), sweep all six surfaces (list toolbar, row menu, record header, ⋯ overflow, related-list section, quick-actions bar) recording every surface it appears on — the expected list is EMPTY; otherwise record half (a) blocked(fixture). ⛔ Do not author the probe at runtime: a runtime-authored action is not served at all, so its empty sweep is no evidence; (b) sweep the same six surfaces for showcase_new_task, the fixture fleet's headless `locations: []` declaration, and record that its list is empty too" @@ -1715,6 +1739,12 @@ "date": "2026-10-13", "change": "knownGaps[2] (new), steps[6] and acceptance[4].verify (fixture gap, #23163 A4; parked on #23121, carried by #23169). steps[6](a) authored a locations-less probe action at runtime. QA run #23163 found runtime-authored actions are not joined into the served object's actions[]: its positive control, the same probe with a location, rendered nowhere either, so the empty sweep proved nothing. The half needs a packaged specimen with no `locations` key, and the stock showcase ships none (the shape action-no-placement warns about). Half (a) now scores blocked(fixture) on stock; half (b) on showcase_new_task is unchanged. Shipping the specimen is a showcase fixture change, not made here", "ref": "#23169" + }, + { + "revision": 11, + "date": "2026-10-16", + "change": "steps[3] names the selection the toolbar dispatch needs (checklist-accuracy finding of QA run #23949, wave #23878): showcase_bulk_reassign on the list toolbar runs on a selected row, and with none selected it only toasts 'select a row first'. The clauses are unchanged", + "ref": "#24036" } ], "enumSource": { @@ -1728,7 +1758,7 @@ "title": "All six validation-rule types enforce on the write path with their exact per-type error codes — and advisory severities flag without blocking", "since": "v15", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -1748,7 +1778,7 @@ "state_machine — create with non-initial status → invalid_initial_state; illegal transition → invalid_transition", "format — bad tax_id / billing_email → invalid_format", "json_schema — off-schema support_config → json_schema_violation; non-JSON string → invalid_json", - "cross_field — project end_date < start_date → rule_violation naming end_date", + "cross_field — project end_date < start_date → rule_violation on start_date (the rule declares fields [start_date, end_date], and the server targets fields[0] — acceptance[1])", "script — spent > budget → rule_violation", "conditional — status 'churned' without churn_reason → the wrapped rule fires; with churn_reason present it does not", "severity — 'error' (the default, validation.zod.ts) blocks; 'warning'/'info' rules that MATCH do not block: the write lands and the message is logged server-side (rule-validator.ts 'only error blocks')" @@ -1787,9 +1817,9 @@ "evidence": "the twins" }, { - "clause": "a MATCHING advisory rule does not block, and its message still surfaces where the contract puts it — the SERVER LOG, not the response: the green→red health jump answers 2xx, the row persists with the jumped value, and the log carries \"Validation rule 'project_health_progression' (warning): Health changed by more than one step…\" — advisory rules stay advisory (only severity 'error' feeds the ValidationError throw)", + "clause": "a MATCHING advisory rule does not block, and its hit still surfaces where the contract puts it — the SERVER LOG and the write answer's `warnings`, never as an error: the green→red health jump answers 2xx AND its write answer carries the hit in `warnings` (one entry: rule project_health_progression, severity 'warning', field health, code invalid_transition — the code the rule's own type raises, copied from its violation — #22726), the row persists with the jumped value, and the log carries \"Validation rule 'project_health_progression' (warning): Health changed by more than one step…\" — advisory rules stay advisory (only severity 'error' feeds the ValidationError throw)", "oracle": "log", - "verify": "the 2xx response + the persisted re-read + the logger.warn line (packages/objectql/src/validation/rule-validator.ts — non-error severities route to opts.logger.warn, never into `errors`). ⛔ Do not require the message in the RESPONSE body — best-effort logging is the documented surface; a run wanting a UI-visible advisory is asking for a feature, not verifying this one", + "verify": "the 2xx response with its `warnings` entry naming the rule (the write answer reports the write's own advisory hits — packages/metadata-protocol/src/protocol.ts, #22726), the persisted re-read, and the logger.warn line (packages/objectql/src/validation/rule-validator.ts — non-error severities route to opts.logger.warn, never into `errors`). Score the entry's rule, severity, field and code (packages/objectql/src/validation/rule-validator.ts#advisoryEvent copies field and code from the violation, so a state_machine advisory reports invalid_transition), not its message wording; a run wanting a UI-visible advisory is asking for a feature, not verifying this one", "evidence": "response + re-read + the captured log line" } ], @@ -1819,6 +1849,12 @@ "date": "2026-08-30", "change": "added the severity axis the matrix silently assumed away: every clause drove severity:'error' (the default) while the spec declares 'warning'/'info' and the evaluator routes them to a non-blocking logger.warn — a matching advisory rule was never proven NOT to block, and its message never proven to surface. New variant line, step, acceptance clause (log oracle — the message's contract surface is the server log, not the response) and two negatives, including that fail-closed (#4649) is severity-scoped. Register corrected against source: the sweep's claim that showcase rules are all default-error is FALSE — project.object.ts ships the advisory project_health_progression precisely for this, so the warning leg is stock-drivable; only 'info' (same code branch) lacks a specimen", "ref": "#sweep-2026-08-30" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "variants[3] and acceptance[4] (clause, verify) corrected (checklist-accuracy finding of QA run #23949, wave #23878, and #23879). variants[3] said cross_field answers rule_violation naming end_date, but the server targets fields[0] = start_date, which is what acceptance[1] already says. And a write that matches an advisory rule now reports the hit in its answer's `warnings` (#22726), so the verify text's 'do not require the message in the response' is stale — the clause now asks for the warnings entry by rule, severity, field and code — invalid_transition, the state_machine's own code, which this card's re-run read — beside the log line. Not loosened: the advisory must still not block, and the log line is still required", + "ref": "#24036" } ] }, @@ -2136,7 +2172,7 @@ "title": "User-built FilterBuilder/SortBuilder toolbar merges with the view filter, restores across nav, and reproduces rows from the URL", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "browser", "personas": [ @@ -2158,7 +2194,7 @@ "open the FilterBuilder toolbar; add 2 conditions (e.g. priority = high, estimate_hours > 4); open the SortBuilder and add a sort (due_date desc); capture the data request", "assert the request $filter is the MERGE of the view's stored rule AND the two toolbar conditions (buildEffectiveFilter/mergeFilterNodes), and $orderby carries the toolbar sort; the returned rows satisfy view-filter AND toolbar conditions", "navigate away via an in-app nav link to another object (the link carries no query string, so the URL state is dropped), then navigate back to showcase_task in_progress; screenshot; confirm the toolbar filter + sort restored", - "read localStorage key list-filters::showcase_task: and confirm it embeds the signed-in user id", + "read where the toolbar filter and sort are kept: the signed-in user's own sys_user_preference row keyed ui.list.showcase_task (GET /api/v1/data/sys_user_preference filtered to that key; its value's perView. carries the filters and the sort — objectui ListPreferencesProvider.tsx, objectui#12418), and its localStorage write-through cache list-state::showcase_task. Confirm both are the signed-in user's: the row's user_id, and the cache key's user segment. (Only the search term stays under the older list-filters::showcase_task: key)", "sign out; sign in as the SECOND account; open the same object+view; confirm the first account's cached toolbar filter does NOT appear", "userFilters/URL: where UserFilters are hosted (an interface page or the userFilters surface — NOT a bare object list, ADR-0053), apply a quick-filter; copy the URL; open it in a fresh tab; confirm the uf_ params (comma-joined, URI-encoded) reproduce the same rows and uf__tab carries the active preset (ADR-0047)", "negative sweep: confirm userFilters (quick-filter chips / uf_* params) do NOT leak onto the bare object list view" @@ -2177,15 +2213,15 @@ "evidence": "the request URL + row order" }, { - "clause": "the toolbar filter + search survive a FULL in-app navigation away and back — restored from localStorage (URL params alone are lost on an in-app nav link that carries no query string; listFilterStorage exists precisely to bridge that)", + "clause": "the toolbar filter + search survive a FULL in-app navigation away and back — restored from the user's own list preference (sys_user_preference ui.list., cached in localStorage list-state::; URL params alone are lost on an in-app nav link that carries no query string, which is what the preference bridges)", "oracle": "dom", - "verify": "after a screenshot confirms the list rendered, the FilterBuilder shows the two conditions and the SortBuilder the sort; the request re-issued on return carries the same merged $filter (app-shell/src/views/listFilterStorage.ts)", + "verify": "after a screenshot confirms the list rendered, the FilterBuilder shows the two conditions and the SortBuilder the sort; the request re-issued on return carries the same merged `filter` param (the console spells it without a `$`; objectui app-shell/src/context/ListPreferencesProvider.tsx)", "evidence": "before/after screenshots + the re-issued request" }, { - "clause": "the localStorage cache is USER-SCOPED — the key embeds the user id (list-filters:::) so a second account on the same browser never reads the first account's cached filters (a filter value can be sensitive)", + "clause": "the list state is USER-SCOPED — it is the user's own sys_user_preference row (user_id = the signed-in user), and its localStorage cache key embeds the user id (list-state::), so a second account on the same browser never reads the first account's filters (a filter value can be sensitive)", "oracle": "dom", - "verify": "read the storage key as account A, then sign in as account B and confirm B's view opens with no A-authored toolbar filter; anon falls to its own 'anon' bucket (buildListFilterKey)", + "verify": "read account A's ui.list.showcase_task preference row and its list-state cache key, then sign in as account B on the same browser and confirm B's view opens with no A-authored toolbar filter (B reads its own row, or none)", "evidence": "the two accounts' storage keys + B's clean toolbar" }, { @@ -2197,7 +2233,7 @@ ], "negative": [ "a toolbar filter that REPLACES the view's stored filter (rows appear that the view filter should exclude) is a FAIL — buildEffectiveFilter merges, it does not overwrite the base", - "userFilters (quick-filter chips / uf_* params) leaking onto a bare OBJECT list view is a FAIL — ADR-0053 suppresses them there by design (filter elements belong to interface pages; the list-view-capabilities item flags the same regression)", + "userFilters (quick-filter chips, uf_ params, the uf__tab preset) leaking onto a bare OBJECT list view is a FAIL — ADR-0053 suppresses them there by design (filter elements belong to interface pages; the list-view-capabilities item flags the same regression). The reserved list-state params an object list writes for ITSELF — uf__filter, uf__search, uf__sort, uf__group (objectui#11860, userFilterUrlState.ts), e.g. uf__sort after a toolbar sort — are the list's own state mirrored into the URL, not userFilters, and do not count", "a cached toolbar filter from account A visible to account B on the same browser is a FAIL — the key embeds the user id precisely to prevent that leak", "an incomplete FilterBuilder row emitted as [field, op, ''] (which matches only empty and silently excludes everything) instead of being dropped is a FAIL — convertFilterGroupToAST skips valueless rows (#1964)" ], @@ -2212,7 +2248,7 @@ }, "source": [ "objectui: packages/plugin-list/src/ListView.tsx (buildEffectiveFilter, convertFilterGroupToAST, mergeFilterNodes; $filter + $orderby assembly)", - "objectui: packages/app-shell/src/views/listFilterStorage.ts (user-scoped localStorage key, debounced write, clear)", + "objectui: packages/app-shell/src/context/ListPreferencesProvider.tsx (objectui#12418 — the per-user ui.list. sys_user_preference row, cached in localStorage list-state::); packages/app-shell/src/views/listFilterStorage.ts now keeps only the search term", "objectui: packages/app-shell/src/views/userFilterUrlState.ts (uf_* params, ADR-0047)", "ADR-0053 (userFilters belong to interface pages, suppressed on object list views); examples/app-showcase/src/ui/views/task.view.ts", "cross-ref: records-forms.list-view-capabilities (saved-view-filter / userFilters negative)" @@ -2229,6 +2265,12 @@ "date": "2026-08-21", "change": "recorded on the item that its automation is pinned exclusively in the objectui repo and is therefore neither runnable nor pin-evidenced from this checkout — one of 23 items in that position, each of which every sweep had been re-deriving from scratch. The knownGap names the two honest ways to score it (hand-drive it as a manual run, or run the pin in an objectui checkout and cite the revision) and forbids the third: treating the `automated` field itself as coverage. The protocol and the exclusive-vs-mixed distinction live once in RUNNER.md; this line is the pointer (#10236 A5)", "ref": "#10236" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "steps[4], acceptance[2] (clause, verify), acceptance[3] (clause, verify), negative[1] and source[1] re-pointed (checklist-accuracy findings of QA run #23949, wave #23878). Since objectui#12418 the toolbar filter and sort live in the user's sys_user_preference row ui.list. (cached in localStorage list-state::), not in localStorage list-filters::: — #23949 read them there, user-scoped. The negative now says that the reserved list-state params (uf__sort and siblings, objectui#11860) are the list's own state and do not count as a userFilters leak. Not loosened: a second account must still see none of the first account's filters", + "ref": "#24036" } ] }, @@ -2332,7 +2374,7 @@ "title": "Concurrent edits collide loudly (OCC 409 + conflict dialog), never silent last-write-wins; inline two-surface edit is ONE atomic OCC-guarded save", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": [ @@ -2352,8 +2394,8 @@ "open the SAME record's detail in session A and session B (both hold the same updated_at)", "in A: edit one field, Save; capture the PATCH carrying If-Match: → 200; the record's updated_at advances", "in B (still holding the STALE updated_at): edit the same field, Save; capture the response", - "assert B receives 409 CONCURRENT_UPDATE and the conflict dialog appears (Reload latest / Overwrite) — NOT a silent overwrite, NOT a bare error", - "in B choose Reload → the form refetches and shows A's value", + "assert B receives 409 CONCURRENT_UPDATE and the conflict dialog appears — on the modal edit form it is occSave's 'Save conflict' dialog offering Keep editing / Overwrite; on an inline edit it is the ConcurrentUpdateDialog offering Reload / Overwrite — NOT a silent overwrite, NOT a bare error", + "Reload lives on the INLINE-edit ConcurrentUpdateDialog (objectui plugin-detail), not on the modal form's dialog: drive this leg as an inline stale save — in B double-click the field on the record page, let A's write land, Save → 409 → choose Reload; the page refetches and shows A's value, and B sends no further write", "in B re-edit and choose Overwrite → the retry re-keys ifMatch to the version the 409 reported → 200; re-read shows exactly one winner (B's value)", "INLINE two-surface (§E1): on the detail, enter the shared inline-edit session; edit one details-BODY field AND one header-HIGHLIGHT field; confirm ONE Save bar; Save; capture the single PATCH and its keys + headers", "stale-ifMatch inline path: repeat the inline save holding a stale updated_at (a concurrent write landed between) and capture the outcome" @@ -2362,17 +2404,17 @@ { "clause": "the losing save is REFUSED with 409 CONCURRENT_UPDATE server-side — never a silent last-write-wins that overwrites A with no signal", "oracle": "api", - "verify": "B's PATCH returns HTTP 409 with code CONCURRENT_UPDATE (framework rest-server.ts error mapping: error.code CONCURRENT_UPDATE / ConcurrentUpdateError → 409); A's value is intact on an independent re-read", + "verify": "B's PATCH returns HTTP 409 with code CONCURRENT_UPDATE — the 409 mapping is packages/types/src/data-error-classification.ts#structuredCodeAnswer (error.code CONCURRENT_UPDATE / ConcurrentUpdateError → 409, the current head as data), and the error class is packages/metadata-protocol/src/protocol.ts#ConcurrentUpdateError; A's value is intact on an independent re-read", "evidence": "B's 409 response + the intact re-read" }, { - "clause": "the conflict surfaces a conflict DIALOG offering Reload/Overwrite (Keep editing) — a structured choice, not a silent overwrite and not a raw stack trace", + "clause": "the conflict surfaces a conflict DIALOG offering a structured choice — Keep editing / Overwrite on the modal edit form (occSave), Reload / Overwrite on an inline edit (ConcurrentUpdateDialog) — not a silent overwrite and not a raw stack trace", "oracle": "dom", "verify": "after screenshot, the ConcurrentUpdateDialog (plugin-detail) / occSave conflict dialog (plugin-form) renders with the racer's version and the two actions", "evidence": "the conflict-dialog screenshot" }, { - "clause": "Reload DISCARDS B's pending edit and refetches — B's form then shows A's value", + "clause": "on the inline-edit conflict dialog (the surface that offers it), Reload DISCARDS B's pending edit and refetches — the page then shows A's value", "oracle": "api", "verify": "after Reload, the form's field equals A's saved value (a fresh GET), and no B write landed", "evidence": "the refetch read + no-write trace" @@ -2414,7 +2456,7 @@ "source": [ "objectui: packages/plugin-detail/src/ConcurrentUpdateDialog.tsx (Reload/Overwrite UX), packages/plugin-form/src/occSave.tsx (If-Match → 409, conflict dialog, re-key overwrite)", "objectui: packages/plugin-detail/src/InlineEditSaveBar.tsx + packages/react/src/context/InlineEditContext.tsx (one save bar, changed-keys draft, ifMatch)", - "framework: packages/rest/src/approvals-routes.ts#mapping (CONCURRENT_UPDATE → 409 mapping), packages/metadata-protocol/src/protocol.ts#updateData updateData (If-Match / expectedVersion)", + "framework: packages/types/src/data-error-classification.ts#structuredCodeAnswer (the CONCURRENT_UPDATE / ConcurrentUpdateError → 409 arm, the current head as data), packages/metadata-protocol/src/protocol.ts#ConcurrentUpdateError (the error class), packages/metadata-protocol/src/protocol.ts#updateData updateData (If-Match / expectedVersion)", "PENDING-GAPS §C concurrent-edit-conflict + §E1 inline-edit atomic two-surface (objectui#2542/2549/2604); cross-ref records-forms.crud-roundtrip" ], "history": [ @@ -2429,6 +2471,12 @@ "date": "2026-08-21", "change": "recorded on the item that its automation is pinned exclusively in the objectui repo and is therefore neither runnable nor pin-evidenced from this checkout — one of 23 items in that position, each of which every sweep had been re-deriving from scratch. The knownGap names the two honest ways to score it (hand-drive it as a manual run, or run the pin in an objectui checkout and cite the revision) and forbids the third: treating the `automated` field itself as coverage. The protocol and the exclusive-vs-mixed distinction live once in RUNNER.md; this line is the pointer (#10236 A5)", "ref": "#10236" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "source[2], acceptance[0].verify, steps[3] and [4] and acceptance[1] and [2] (clauses) re-pointed (the carried reading and a checklist-accuracy finding of QA run #23949, wave #23878). The source cited packages/rest/src/approvals-routes.ts#mapping for CONCURRENT_UPDATE → 409, but that map holds only the approvals codes; the row is structuredCodeAnswer in packages/types/src/data-error-classification.ts and the class is ConcurrentUpdateError in metadata-protocol's protocol.ts — and acceptance[0]'s 'rest-server.ts error mapping' was not where it lives either. 'In B choose Reload' could not be followed on the modal form, whose dialog offers Keep editing / Overwrite; Reload is on the inline-edit ConcurrentUpdateDialog, which is where #23949 drove it. Not loosened: a silent overwrite, a bare error, or a Reload that lets B's write land still FAILS", + "ref": "#24036" } ] }, @@ -2594,7 +2642,7 @@ "title": "Grid personalization persists across reload: column resize/reorder/pin, row-height, group-by (with API-true totals), row-color", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "browser", "personas": [ @@ -2620,10 +2668,10 @@ "steps": [ "open showcase_task grid; screenshot", "resize a column (reorder two columns only on a grid that sets reorderableColumns; there is no runtime pin control — acceptance[0]), and set a row-height mode (compact/short/medium/tall/extra_tall)", - "reload; confirm the columnState (widths, and the order where reorder is enabled) and row-height restored from localStorage grid-columns-showcase_task- (or the persisted view override via dataSource.updateViewConfig)", - "group-by status; confirm per-group headers appear and each group shows its summarizeField (estimate_hours) total; clear grouping and confirm the headers are removed cleanly", - "cross-check one group's total against a direct API aggregate: GET /api/v1/data/showcase_task grouped client-side from the raw rows", - "set a row-color rule; confirm rows paint by the rule (useRowColor)", + "reload; confirm the columnState (widths, and the order where reorder is enabled) and row-height restored — they now persist in the signed-in user's own list preference, sys_user_preference ui.list.showcase_task (perView..columnState / rowHeight, objectui#12418), with localStorage grid-columns-showcase_task[-] as the component's cache; the old per-view override through dataSource.updateViewConfig is retired (objectui#12504). Also reload in a FRESH browser context as the same user: the layout follows the user", + "group-by status; confirm per-group headers appear and each group shows its record count — summarizeField (estimate_hours) is declared on the KANBAN config of task.view.ts, not on the grid, so grid groups carry counts; clear grouping and confirm the headers are removed cleanly", + "cross-check each group's count against a direct per-group server count (GET /api/v1/data/showcase_task filtered to that status, read its total)", + "read the row-color rule the stock Grid view declares — rowColor on priority with the colors map low slate / medium blue / high amber / urgent red (examples/app-showcase/src/ui/views/task.view.ts); there is no runtime row-color authoring control — and confirm rows paint by it (useRowColor, the -100 tint of each name)", "reload once more; confirm every personalization survives; capture a screenshot per variant" ], "acceptance": [ @@ -2640,9 +2688,9 @@ "evidence": "before/after screenshots" }, { - "clause": "group-by renders per-group headers and CLEARS cleanly, and the grouped totals MATCH a direct API aggregate of the grouping/summary field — grouping summarizes the real rows, not the rendered page", + "clause": "group-by renders per-group headers and CLEARS cleanly, and each group's count MATCHES a direct server count for that group — grouping summarizes the real rows, not the rendered page", "oracle": "api", - "verify": "compare a group's summarizeField total against GET /api/v1/data/showcase_task aggregated client-side (ObjectGrid grouping + useColumnSummary); clearing grouping removes the headers", + "verify": "compare every group's count against a per-group server count from GET /api/v1/data/showcase_task (ObjectGrid grouping); clearing grouping removes the headers. A group count equal to the rendered page's rows rather than the server count is the FAIL", "evidence": "board screenshot + the aggregate check" }, { @@ -2652,10 +2700,10 @@ "evidence": "post-reload screenshot" }, { - "clause": "the column-state store is per-browser and deliberately NOT user-scoped (cosmetic), UNLIKE the runtime filter store — so a shared column layout across accounts on one browser is by-design, not a leak to fail on", + "clause": "the column state follows the USER, not the browser: since objectui#12418 widths and row height persist in the user's own sys_user_preference row (ui.list., perView..columnState / rowHeight), so they restore for that user in a fresh browser and never for another account; the component's localStorage key grid-columns-[-] carries no user id and is only a cache under that row", "oracle": "dom", - "verify": "the key is grid-columns-[-] with no user id (listFilterStorage.ts documents the deliberate asymmetry: widths cosmetic, filter values sensitive)", - "evidence": "the storage key" + "verify": "the sys_user_preference ui.list.showcase_task row carries the widths / row height under the signed-in user's user_id, and the same user in a fresh browser context sees them restored; a second account on the same browser does not (objectui app-shell/src/context/ListPreferencesProvider.tsx). The localStorage grid-columns-[-] key carries no user id and is only the cache", + "evidence": "the preference row + the fresh-context restore + the second account's default layout" } ], "negative": [ @@ -2695,6 +2743,12 @@ "date": "2026-10-06", "change": "partial-coverage gap of QA run #21782 parked on #22017 (row 6), re-pointed clause. A1's reorder / pin half was blocked: reorderableColumns is off and there is no runtime pin control. Verified in source: reorderableColumns is an ObjectGridPropsSchema prop (packages/spec/src/ui/component.zod.ts) that ObjectGrid defaults to false, never a ListView key; pinning is the author's ListColumnSchema `pinned`; the persisted columnState holds widths and order only. The clause asserted two user personalizations the stock list view does not offer. A1 now asserts resize persistence, reorder only where enabled, and an author-pinned column after reload; the verify, two variants, steps 2-3 and source follow", "ref": "#22017" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "steps[2], [3], [4] and [5] and acceptance[2] (clause, verify) and [4] (clause, verify, evidence) re-pointed (checklist-accuracy findings of QA run #23949, wave #23878). Column state and row height now persist in the user's own sys_user_preference row (ui.list., objectui#12418) and restore in a fresh browser — #23949 read them there — so acceptance[4]'s 'deliberately NOT user-scoped' describes a store the console replaced; the clause now asserts the per-user row (a stale spec re-pointed to objectui#12418). summarizeField is a kanban config, so grid groups show counts and the reconciliation is per-group count; the row-color step now names the rule the stock Grid view declares, since no runtime authoring control exists; the updateViewConfig override is retired (objectui#12504). Not loosened: counts must still match the server, and another account must not see the layout", + "ref": "#24036" } ] }, @@ -2899,7 +2953,7 @@ "title": "Lookup picker quick-create: a user-facing zero-hit lookup opens the referenced create form and adopts the new id; system references offer no quick-create", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "browser", "personas": [ @@ -2921,7 +2975,7 @@ "click create-new; the referenced object's FULL create form opens (via the ActionProvider modal); fill required fields and save", "confirm the picker ADOPTS the newly created id and the parent form's field now holds it", "save the parent; re-read the parent over the API — its reference resolves to the new id", - "type a search WITH hits; confirm each keystroke issues a scoped server request (not client filtering of a preloaded set)", + "type a search WITH hits; confirm the picker issues a scoped server request per SETTLED term — it debounces, so a typed word sends one request (search=TERM) once typing pauses, not one per keystroke; still a server find, never client filtering of a preloaded set", "open a lookup to a system/user-directory reference (sys_/cloud_/ai_ or user/users); confirm NO create-new affordance is offered" ], "acceptance": [ @@ -2980,6 +3034,12 @@ "date": "2026-08-21", "change": "recorded on the item that its automation is pinned exclusively in the objectui repo and is therefore neither runnable nor pin-evidenced from this checkout — one of 23 items in that position, each of which every sweep had been re-deriving from scratch. The knownGap names the two honest ways to score it (hand-drive it as a manual run, or run the pin in an objectui checkout and cite the revision) and forbids the third: treating the `automated` field itself as coverage. The protocol and the exclusive-vs-mixed distinction live once in RUNNER.md; this line is the pointer (#10236 A5)", "ref": "#10236" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "steps[5] corrected (checklist-accuracy finding of QA run #23949, wave #23878): the picker debounces, so it sends one scoped server request per settled term, not one per keystroke — still server-side, which is what acceptance[2] scores. Wording only: no request or oracle changed", + "ref": "#24036" } ] }, @@ -3083,7 +3143,7 @@ "title": "Async import job: terminal state + per-row results, undo removes exactly the imported rows, cancel mid-job leaves a coherent partial", "since": "v17", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "mixed", "personas": [ @@ -3094,7 +3154,7 @@ "requires": [ "an import-job-capable client wired into the console AND an object with an async-import route (framework packages/rest/src/rest-route-ledger.ts: POST /api/v1/data/:object/import/jobs, /import/jobs/:jobId/{cancel,undo,results}) — the STOCK console already satisfies the client half (run #21845, console pin 2e818d0b51ec); no extra wiring is needed", "the objectui import specs, which self-gate on IMPORT_CONSOLE_LIVE=1 (console) or a reachable import harness /live.html", - "RUNTIME FIXTURE — the live-gated console spec (proved by run #21845; nothing committed in either repo): in an objectui checkout, run e2e/import-console/import-console-undo.spec.ts with IMPORT_CONSOLE_LIVE=1 against the booted showcase, from a SCRATCH COPY of the spec whose page goto path is the console-mounted /_console/apps/{app}/{object} (the spec's own path does not carry the /_console mount the framework serves the console under)" + "RUNTIME FIXTURE — the live-gated console spec (proved by run #21845; nothing committed in either repo): in an objectui checkout, run e2e/import-console/import-console-undo.spec.ts with IMPORT_CONSOLE_LIVE=1 against the booted showcase, from a SCRATCH COPY of the spec whose page goto path is the console-mounted /_console/apps/{app}/{object} (the spec's own path does not carry the /_console mount the framework serves the console under). The spec imports a name,email,status CSV and counts rows on LIVE_IMPORT_OBJECT, and NO showcase object carries those three fields — author a scratch object first (e.g. qa_lead with name / email / status text fields and a declared sharingModel, in a writable package, published) and run with LIVE_IMPORT_APP=showcase_app LIVE_IMPORT_OBJECT=qa_lead, as QA run #23949 did" ], "knownGaps": [ "CLOSED for runs by the live-gated console spec in requires (#21845): e2e/import-console/import-console-undo.spec.ts skips unless IMPORT_CONSOLE_LIVE=1, and its goto path needs the /_console mount — with both supplied it runs against the stock console. A skip without the gate is the control, never a result. The harness twin e2e/import-harness/import-undo.spec.ts still skips unless the harness origin serves /live.html; it is optional evidence, and the API half below needs no gate at all", @@ -3171,6 +3231,12 @@ "date": "2026-10-05", "change": "blocked lifted: run #21845 found that the stock console already satisfies the 'import-job-capable client' gate (console pin 2e818d0b51ec) and passed the objectui console spec twice with IMPORT_CONSOLE_LIVE=1 from a scratch copy whose goto path is /_console/apps/{app}/{object}, plus the API half. That recipe is written into fixtures.requires and the gate knownGap is marked CLOSED-for-runs, not deleted. The API step now says that creation is POST /api/v1/data/:object/import/jobs and that progress, results, cancel, undo and the list all live under /api/v1/data/import/jobs. The job-list reads now treat revertedAt as absent, not null, until the job is reverted — the old { revertedAt:null } shape would fail a healthy list (card #21851 entry 4)", "ref": "#21845" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "fixtures.requires[2] names the scratch object the console spec needs (checklist-accuracy finding of QA run #23949, wave #23878): the spec imports a name,email,status CSV onto LIVE_IMPORT_OBJECT, and no showcase object has those three fields, so #23949 authored qa_lead and ran with LIVE_IMPORT_APP=showcase_app LIVE_IMPORT_OBJECT=qa_lead. The clauses are unchanged", + "ref": "#24036" } ] }, @@ -3179,7 +3245,7 @@ "title": "Object lifecycle hooks fire on the write path with the right timing, gate, async and error semantics — driven over /api/v1/data/*, oracled by the record effect and the log line", "since": "v15", "status": "active", - "revision": 4, + "revision": 5, "priority": "P1", "surface": "mixed", "personas": [ @@ -3221,8 +3287,8 @@ "condition fails CLOSED (#4775) — SCRATCH: an unevaluable/uncompilable condition ABORTS the operation and is NOT softened by onError:'log' nor fire-and-forgotten (the gate runs OUTSIDE both); add a scratch hook with a broken condition (unit-pinned in hook-wrappers.ts)" ], "steps": [ - "boot showcase isolated; sign in as seeded admin; confirm the four hooks this item drives actually registered before asserting any no-fire — GET /api/v1/meta/types/hook (or the boot log) lists showcase_normalize_task_title / showcase_stamp_inquiry_defaults / showcase_audit_task_completion / showcase_warn_over_budget (a hook that never bound fakes every no-fire — seed-data-thin)", - "stamp defaults (beforeInsert): POST /api/v1/data/showcase_inquiry {name:'os-qa-'} OMITTING status AND source; re-read via GET and confirm source == 'web' — the hook is the SOLE producer of that value (the source field carries no default), so a correct read proves the hook fired; status == 'new' corroborates but is NOT hook-attributable alone (the 'new' status option is default:true)", + "boot showcase isolated WITH OS_LOG_LEVEL=info exported (or --log-level info): the audit oracle is a ctx.log.info line, and `os dev` defaults the kernel logger to warn (packages/cli/src/utils/log-level.ts DEFAULT_LOG_LEVEL), so on a default boot a correct product prints no audit line and every 'fires' clause reads as a no-fire; sign in as seeded admin; confirm the four hooks this item drives actually registered before asserting any no-fire — GET /api/v1/meta/types/hook (or the boot log) lists showcase_normalize_task_title / showcase_stamp_inquiry_defaults / showcase_audit_task_completion / showcase_warn_over_budget (a hook that never bound fakes every no-fire — seed-data-thin)", + "stamp defaults (beforeInsert): POST /api/v1/data/showcase_inquiry {name:'os-qa-', email, message} — email and message are required, so a name-only body answers 400 VALIDATION_FAILED before any hook runs — OMITTING status AND source; re-read via GET and confirm source == 'web' — the hook is the SOLE producer of that value (the source field carries no default), so a correct read proves the hook fired; status == 'new' corroborates but is NOT hook-attributable alone (the 'new' status option is default:true)", "title trim (beforeInsert + beforeUpdate): POST /api/v1/data/showcase_task with title ' os-qa- ' (leading/trailing spaces) and valid required fields; re-read → title == 'os-qa-' (insert trim); then PATCH the same row's title to another padded value and re-read → trimmed again (update trim); ONE multi-event hook covered both", "completion audit — FIRES (transition): PATCH /api/v1/data/showcase_task/ {done:true} (previous done:false); after the HTTP response resolves, settle briefly (the audit is async fire-and-forget, it runs after the write returns), then read the server log for 'task completed: Build homepage'", "completion audit — DOES NOT FIRE (two-root proof): PATCH an already-done task (, done:true) changing ONLY priority (medium→high), leaving done untouched; settle; confirm NO new 'task completed: App wireframes' line — previous.done == true makes the transition condition false even though record.done == true", @@ -3284,7 +3350,7 @@ { "clause": "a condition that cannot be evaluated FAILS CLOSED (#4775): the write is ABORTED, not silently skipped — and it is NOT softened by onError:'log' nor fire-and-forgotten, because the condition gate runs OUTSIDE the async/retry/onError wrappers", "oracle": "api", - "verify": "scratch hook with a broken/uncompilable condition on an object; a write to that object returns a HookConditionError-class refusal and the row does not land (hook-wrappers.ts: conditionFn throws outside runWithErrorPolicy)", + "verify": "scratch hook whose condition COMPILES but cannot be evaluated at runtime (e.g. int(record.name) > 0, written to a row whose name is not numeric); a write to that object returns a HookConditionError-class refusal and the row does not land (hook-wrappers.ts: conditionFn throws outside runWithErrorPolicy). An UNCOMPILABLE condition never reaches this gate: it is refused at authoring (PUT /api/v1/meta/hook → 422 INVALID_METADATA, expression-invalid), so it cannot stand in for the runtime fail-closed probe", "evidence": "the refusal + a post-attempt count showing no row" }, { @@ -3346,6 +3412,12 @@ "date": "2026-10-13", "change": "the onError:'log' step and acceptance[7].verify quote the log line the product writes (wording drift, #23166 A7; parked on #23121, carried by #23169). For an async afterUpdate hook declared onError:'log', hook-wrappers.ts runWithErrorPolicy catches the throw, logs ERROR '[hook] handler failed (onError=log; suppressing)' and returns. The '[hook] async handler error (fire-and-forget)' line the item quoted is the fire-and-forget catch, which only an async hook without onError:'log' reaches. QA run #23166 read the first line. Read from source at 6d7f4df3c. The clause's verdict logic is unchanged", "ref": "#23169" + }, + { + "revision": 5, + "date": "2026-10-16", + "change": "steps[0], steps[1] and acceptance[8].verify corrected (checklist-accuracy findings of QA run #23949, wave #23878, and of this card's re-run). The audit oracle is a ctx.log.info line, and the CLI's default log level is warn, so the boot step now exports OS_LOG_LEVEL=info (#23949 ran on an info boot). The fail-closed scratch must be runtime-unevaluable, not uncompilable: an uncompilable condition is refused at authoring with 422 INVALID_METADATA expression-invalid and never reaches the runtime gate. And steps[1]'s inquiry body named only `name`, which answers 400 VALIDATION_FAILED (email and message are required) before any hook runs; the step now carries them. Not loosened: a write that lands past an unevaluable condition still FAILS", + "ref": "#24036" } ] }, @@ -3499,7 +3571,7 @@ "title": "Cascade clear-semantics and the server boundary: stale-child clear on parent change, the WRITTEN-value server gate, and where cascade scope is client-only", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": [ @@ -3519,7 +3591,7 @@ }, "steps": [ "boot showcase isolated; sign in as the seeded admin; keep an API token for direct /api/v1/data/* writes", - "SELECT client clear: open a New Cascading Select (/_console/apps/com.example.showcase/showcase_cascade); pick country=cn, province=zj; screenshot; switch country=us; screenshot; enumerate province options (expect {California,Texas}) and confirm zj CLEARED and not carried into the us submit", + "SELECT client clear: open a New Cascading Select (/_console/apps/showcase_app/showcase_cascade — the app name is the canonical segment; the console also accepts the package id com.example.showcase there); pick country=cn, province=zj; screenshot; switch country=us; screenshot; enumerate province options (expect {California,Texas}) and confirm zj CLEARED and not carried into the us submit", "SELECT server gate (written value): direct POST /api/v1/data/showcase_cascade {country:'cn', province:'ca'} → capture the refusal; then {country:'cn', province:'zj'} → capture success", "SELECT stale-child EDGE (gap probe): create {country:'cn', province:'zj'}; PATCH only {country:'us'} (omit province); GET the row and record whether province='zj' (now invalid for us) is still stored and the PATCH returned 200 — expected: accepted, stale value kept (server does not revalidate an unwritten child)", "LOOKUP twin client re-query + clear: on a New Invoice, choose account=Northwind and capture the contact picker's candidate request (scoped to Northwind); pick a Northwind contact; switch account=Contoso; capture the re-issued (Contoso-scoped) candidate request and confirm the Northwind contact CLEARED from the field", @@ -3599,6 +3671,12 @@ "date": "2026-10-09", "change": "source line corrected: the server option gate no longer fails open on an unevaluable predicate. Since #22402 (ruling A, ADR-0137 D2 on the write path) it refuses the write as rule_violation / unevaluable, naming the option, the field and the fault; only a system write with no acting user whose predicate reads one is still admitted. No step or oracle of this item moves: its cascade predicates evaluate cleanly on the write path (a payload without country reads null == 'cn', a clean false).", "ref": "claude/issue-22402-option-gate-fails-closed" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "steps[1] uses the canonical app segment (checklist-accuracy finding of QA run #23949, wave #23878): /_console/apps/showcase_app/… names the app; the package-id spelling still resolves. Wording only: no request or oracle changed", + "ref": "#24036" } ] }, @@ -3709,7 +3787,7 @@ "title": "fieldGroups[].visibleWhen section predicate: both spellings accepted, the parse normalizes to the envelope, retired visibleOn refused with the located prescription, derivation carries it verbatim — the console gates the section on the form AND the detail page (objectui#11630 fixed)", "since": "v17", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": [ @@ -3728,7 +3806,7 @@ }, "steps": [ "boot showcase isolated; sign in as the seeded admin; keep an API token", - "author the scratch carrier: install a scratch package, then PUT /api/v1/meta/object/qa_group_gate?package= with a `kind` select (options basic|pro, basic default), two member fields declaring group: 'pro_details', and fieldGroups: [{ key: 'pro_details', label: 'Pro details', visibleWhen: \"record.kind == 'pro'\" }] — the same call sequence the area's qa-feeds-disabled recipe grounds", + "author the scratch carrier: install a scratch package, then PUT /api/v1/meta/object/qa_group_gate?package= with a `kind` select (options basic|pro, basic default), two member fields declaring group: 'pro_details', fieldGroups: [{ key: 'pro_details', label: 'Pro details', visibleWhen: \"record.kind == 'pro'\" }], and highlightFields: ['kind'] — without highlightFields the derived detail page puts every field in its highlight strip and draws no body sections, so step 5's detail half has nothing to observe — the same call sequence the area's qa-feeds-disabled recipe grounds", "GET the object back over /api/v1/meta and read fieldGroups[0].visibleWhen — expect the body AS AUTHORED: a bare string comes back a bare string, because runtime-authored object metadata persists verbatim (ADR-0005 appendix (c)). Then PUT the envelope spelling and read it back the same way. The string → envelope normalization belongs to the schema parse and is proven by the batch20 pin (step 5), not by this read", "PUT a second draft spelling the key `visibleOn` and capture the refusal body", "run the spec pins: vitest run over packages/spec/src/data/object-strictness-batch20.test.ts and field-group-layout.test.ts", @@ -3803,6 +3881,12 @@ "date": "2026-10-13", "change": "acceptance[3] (clause and verify), knownGaps[1], the title and source[2] re-pointed: the detail half is no longer expected to fail (stale spec, #23166 A3; parked on #23121, carried by #23169). At objectui 1fb1ced36243 the detail page writes each field group as a `{ group }` reference (buildDefaultPageSchema.ts resolveDetailSections), and record:details evaluates the group's visibleWhen per record, pinned by record-details.groupVisibleWhen-11630.test.tsx. QA run #23166 (objectstack 36b2889c) saw the group gate live on the form and on the detail page. Re-read from objectui source at that pin, still this repo's .objectui-sha", "ref": "#23169" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "steps[1] gains highlightFields (checklist-accuracy finding of QA run #23949, wave #23878): with none declared, the derived detail page for qa_group_gate puts every field in the highlight strip and draws no body sections, so the detail half of the console clause could not be observed; #23949 authored highlightFields ['kind']. The clauses are unchanged", + "ref": "#24036" } ] }, @@ -3811,7 +3895,7 @@ "title": "Field-level `unique` on the write path: colliding writes refused 409 UNIQUE_VIOLATION on both driver families, NULL-distinct, organization-scoped by default, whole-batch refusal, autonumber resync converges", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -3840,9 +3924,9 @@ ], "acceptance": [ { - "clause": "a colliding create is REFUSED, not landed: status 409, code UNIQUE_VIOLATION (ADR-0112 envelope), a message that NAMES THE OBJECT ('Duplicate record refused on 'qa_unique': …') and states 'No record was written.', with NO driver prefix and no driver statement or bound values — and the row count is unchanged. On the live SQLite leg the field is named only when the driver reports a single offending column (a tenant-scoped unique index is composite, so the sentence reads 'a unique constraint already holds these values'); the `object.field` wording is the memory driver's and stays a memory-pin assertion", + "clause": "a colliding create is REFUSED, not landed: status 409, code UNIQUE_VIOLATION (ADR-0112 envelope), whose developerMessage NAMES THE OBJECT ('Duplicate record refused on 'qa_unique': …') and states 'No record was written.', while the user-facing `error` stays generic ('A record with this value already exists'); NEITHER carries a driver prefix, a driver statement or bound values — and the row count is unchanged. On the live SQLite leg the field is named only when the driver reports a single offending column (a tenant-scoped unique index is composite, so the sentence reads 'a unique constraint already holds these values'); the `object.field` wording is the memory driver's and stays a memory-pin assertion", "oracle": "api", - "verify": "the refusal body + a post-refusal count; the SQL-family envelope is single-sourced at packages/objectql/src/duplicate-record-error.ts (buildDuplicateMessage / envelopeUniqueViolation); the memory driver's `object.field` sentence (memory-unique-constraint.ts uniqueViolationError) is asserted by memory-unique-constraint.test.ts, not live", + "verify": "the refusal body — status, code, and the developerMessage naming the object, read from developerMessage, not from the generic `error` — + a post-refusal count; the SQL-family envelope is single-sourced at packages/objectql/src/duplicate-record-error.ts (buildDuplicateMessage / envelopeUniqueViolation); the memory driver's `object.field` sentence (memory-unique-constraint.ts uniqueViolationError) is asserted by memory-unique-constraint.test.ts, not live", "evidence": "refusal response + before/after counts" }, { @@ -3909,6 +3993,12 @@ "date": "2026-10-04", "change": "A1, step 1 and fixtures re-pointed to the stock SQLite store: the in-memory engine is no longer a boot store (every boot door refuses it), so the live legs run on SQLite and answer 409 UNIQUE_VIOLATION with the object named and 'No record was written.' (duplicate-record-error.ts buildDuplicateMessage); the `object.field` wording stays a memory-pin assertion (memory-unique-constraint.test.ts). 'memory driver (the stock default)' dropped; the SQL knownGap inverted into a memory-leg-is-pin-only gap; negative and source follow. Stale clause. Source 9a4182a75 (#21598)", "ref": "#21735" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "acceptance[0] (clause, verify) re-pointed (checklist-accuracy finding of QA run #23949, wave #23878): on the SQLite leg the object-naming sentences are in the envelope's developerMessage, and the user-facing `error` is the generic 'A record with this value already exists' — #23949 read them so. The clause now reads the naming where it lives. Not loosened: the object must still be named, no driver text may appear in either field, and nothing may be written", + "ref": "#24036" } ] }, @@ -4298,7 +4388,7 @@ "title": "A validation rule reads a parent field one hop through a lookup", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": [ @@ -4332,7 +4422,7 @@ { "clause": "the write is REFUSED when the parent field trips the rule, carrying the AUTHORED message, not a fault", "oracle": "api", - "verify": "400 VALIDATION_FAILED naming the rule no_invoice_for_churned_account, with the authored sentence about reactivating the account — a refusal that reads 'could not be evaluated' is a FAIL, not a pass", + "verify": "400 VALIDATION_FAILED whose fields[] entry is { field: '_record', code: 'rule_violation' } and whose message is the rule's AUTHORED message — the one no_invoice_for_churned_account declares in the object metadata (the sentence about reactivating the account). A rule that evaluates true answers its authored message, not its name: the rule name appears in no field of the body (packages/objectql/src/validation/rule-validator.ts). Score the code, the field and that the message equals the declared one; a refusal that reads 'could not be evaluated' is a FAIL, not a pass", "evidence": "the rejection envelope" }, { @@ -4377,6 +4467,12 @@ "date": "2026-10-06", "change": "partial-coverage gap of QA run #21720 parked on #22017 (row 14), re-pointed clause. A4 was blocked(fixture): no stock persona has invoice CRUD without account read, and the item's knownGap said otherwise. Verified in source: showcase_member_default grants showcase_account read to every member (examples/app-showcase/src/security/permission-sets.ts), and grants are additive, so the persona cannot be provisioned on stock. The knownGap, the second persona, step 4 and A4's verify now say so. A4 is scored from engine-predicate-relationship.test.ts (named in `automated`) or live on the scratch-object substitute the record measured", "ref": "#22017" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "acceptance[1].verify corrected (checklist-accuracy finding of QA run #23949, wave #23878): the 400 carries the authored message, not the rule name — a rule that evaluates true answers {field: '_record', code: 'rule_violation', message: authored} (rule-validator.ts), and #23949 saw no rule name in the body. The verify text now scores the code, the field and the declared message. Not loosened: a 'could not be evaluated' refusal still FAILS", + "ref": "#24036" } ] }, diff --git a/docs/qa/platform-checklist/areas/studio-authoring.json b/docs/qa/platform-checklist/areas/studio-authoring.json index 84319ec94e..cd048b2fd4 100644 --- a/docs/qa/platform-checklist/areas/studio-authoring.json +++ b/docs/qa/platform-checklist/areas/studio-authoring.json @@ -95,7 +95,7 @@ "title": "Object designer round-trip: field add/edit/reorder persists to metadata, identifiers derive and survive typing", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P1", "surface": "mixed", "personas": ["admin"], @@ -109,20 +109,20 @@ ] }, "steps": [ - "open the object designer on the writable package: /_console/apps/com.objectstack.studio/metadata/object/new?package=", - "TYPE the object name repair_asset character by character (never .fill — the F2 per-keystroke-sanitisation bug only reproduces when typed) and confirm the underscore survives", + "open the object designer on the writable package: /_console/apps/setup/metadata/object/new?package=PKG (PKG = the writable package's id; note its namespace NS, from GET /api/v1/packages). ⛔ Not /_console/apps/com.objectstack.studio/…: the studio app is not loaded on a stock boot (RUNNER.md environment facts), so that route renders inside another app's chrome", + "TYPE the object name NS_repair_asset character by character (never .fill — the F2 per-keystroke-sanitisation bug only reproduces when typed) and confirm both underscores survive. The NS_ prefix is required: in a namespaced writable package the publish gate refuses an unprefixed object name (NAMESPACE_PREFIX). Type the name after the label, or re-check it before saving — objectui#12680: typing the Label after a hand-typed name overwrites it and drops the prefix", "add a Text field, set its label to 'Asset Code', blur — the API name input must derive asset_code (F3)", "add a Picklist field and author its 3 real options, THEN add one EMPTY option row; wait out the debounced live validation — no validation banner may appear for the empty row (F4). ⚠ Order matters: with NO real option yet the select-needs-options banner fires, legitimately — the empty rows ARE dropped, so the field has no options — which collides with a literal 'no banner' (and with objectui F4's toHaveCount(0) the same way). Assert the empty-row half with real options present", - "Save draft, Publish; GET /api/v1/meta/object/repair_asset and record the full field list and its order", + "Save draft, Publish; GET /api/v1/meta/object/NS_repair_asset and record the full field list and its order", "reopen the designer: rename one picklist value, drag-reorder the fields, Save draft + Publish again", - "GET /api/v1/meta/object/repair_asset again — the rename AND the new field order must be in the persisted metadata, not just the canvas", - "create a record via the Records tab and GET /api/v1/data/repair_asset/ — the stored column key must be the derived API name (asset_code)" + "GET /api/v1/meta/object/NS_repair_asset again — the rename AND the new field order must be in the persisted metadata, not just the canvas", + "create a record via the Records tab and GET /api/v1/data/NS_repair_asset/ — the stored column key must be the derived API name (asset_code)" ], "acceptance": [ { "clause": "designer edits persist to the metadata store: added, edited, and reordered fields read back from the meta API exactly as authored — the designer repaint is never the oracle", "oracle": "api", - "verify": "before/after GET /api/v1/meta/object/repair_asset: field set, picklist values, and field ORDER all match the authored state after each publish", + "verify": "before/after GET /api/v1/meta/object/NS_repair_asset: field set, picklist values, and field ORDER all match the authored state after each publish", "evidence": "the two meta reads, diffed" }, { @@ -132,9 +132,9 @@ "evidence": "designer screenshot + the record read" }, { - "clause": "typed identifiers survive per-keystroke sanitisation — repair_asset keeps its underscore when typed char-by-char", + "clause": "typed identifiers survive per-keystroke sanitisation — NS_repair_asset keeps its underscores when typed char-by-char", "oracle": "dom", - "verify": "after a screenshot confirms the designer rendered, the name input's value equals repair_asset (pinned by objectui F2; pre-fix this yielded 'repairasset')", + "verify": "after a screenshot confirms the designer rendered, the name input's value equals the typed NS_repair_asset, character for character (pinned by objectui F2; pre-fix repair_asset yielded 'repairasset')", "evidence": "screenshot + input value read" }, { @@ -167,6 +167,12 @@ "date": "2026-10-13", "change": "steps[3] and acceptance[3] (clause and verify) add the empty option row after the real options (assertion defect, #23166 A3; parked on #23121, carried by #23169). The empty rows are dropped, so a picklist with no real option yet has none, and the select-needs-options banner fires correctly; the literal 'no banner for the empty row' collided with it, as objectui F4's toHaveCount(0) does. QA run #23166 asserted the empty-row half with real options present and passed", "ref": "#23169" + }, + { + "revision": 4, + "date": "2026-10-16", + "change": "steps[0], [1], [4], [6] and [7] and acceptance[0].verify and [2] (clause, verify) re-pointed (checklist-accuracy finding 3 of QA run #23942, wave #23878). The object name repair_asset cannot publish in a namespaced writable package — the publish gate answers NAMESPACE_PREFIX — so #23942 minted repairs_repair_asset; the steps and reads now use NS_repair_asset, which still carries the underscores the F2 sanitisation clause tests. The route /_console/apps/com.objectstack.studio/… renders inside the Showcase chrome because the studio app is not loaded on a stock boot, so step 0 opens the designer under the Setup app. Not loosened: the typed name must still read back character for character", + "ref": "#24036" } ] }, @@ -336,7 +342,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": 5, + "revision": 6, "priority": "P1", "surface": "mixed", "personas": ["admin (authors drafts)", "end user (must not see drafts)"], @@ -350,7 +356,7 @@ "steps": [ "author a dashboard draft: PUT /api/v1/meta/dashboard/qa_lifecycle_probe?mode=draft with one dataset-bound widget (dataset: showcase_task_metrics, dimensions: ['status'], values: ['task_count']) — the same door areas/dashboards.json exercises", "record the receipt: { success, version, seq, state: 'draft' }", - "BOTH sides pre-publish: GET /api/v1/meta/dashboard/qa_lifecycle_probe as the runtime read → 404 (never published) — the draft body must not be served; in Studio the item shows the 'Unpublished draft' badge and the 'Changes · n' counter", + "BOTH sides pre-publish: GET /api/v1/meta/dashboard/qa_lifecycle_probe as the runtime read → 404 (never published) — the draft body must not be served; in Studio the item shows the 'Unpublished draft' chip and a numbered pill on the Changes button (objectui#12459). For the Studio half stage the draft in a WRITABLE package (?package=PKG on the draft PUT): the pill counts the pending drafts of the package in scope, and an env-wide, package-less draft is counted in no package scope, so it shows no pill. Open the item in Studio through that package: stage, in the same package, an app draft whose navigation names the dashboard (e.g. an entry of type dashboard, dashboardName qa_lifecycle_probe) and select that entry in the Interfaces pillar — a package with no app shows 'This package has no app yet' and no dashboard to select, so no chip can render; the pill then counts both drafts", "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", @@ -401,9 +407,9 @@ "evidence": "the refused publish + follow-up GET" }, { - "clause": "Studio chrome tells the truth: 'Unpublished draft' badge and 'Changes · n' counter while pending, cleared after publish", + "clause": "Studio chrome tells the truth: the 'Unpublished draft' chip and the numbered pill on the Changes button while pending, cleared after publish", "oracle": "screenshot", - "verify": "badge/counter visible pre-publish, gone post-publish (the audit found this model 'reads consistently everywhere')", + "verify": "for a draft staged in a writable package (an env-wide draft is counted in no package scope, so it shows no pill): chip and pill visible pre-publish, both gone post-publish and after a reload (the audit found this model 'reads consistently everywhere')", "evidence": "before/after Studio screenshots" }, { @@ -460,7 +466,8 @@ { "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" }, { "revision": 4, "date": "2026-08-17", "change": "re-spell after [#7941]: the rollback step and the audit clause named X-Actor as an alternate actor source alongside the session identity; #7941 removed the header limb from resolveMetaWriteActor entirely (landed as commit 4dc8a618e, PR #9119) so it is now the SOLE source and X-Actor is ignored outright, not consulted even as a fallback. Re-spelled the rollback step, the audit acceptance clause, and the rest-server.ts source pointer to name the single source; added a new step + acceptance clause pairing the inverted check the finding asked for — send X-Actor and confirm it does NOT change the recorded actor — since that is now a meaningful QA-layer assertion nothing else covers (the unit pins live in packages/rest/src/meta-write-actor-identity.test.ts, added as a source pointer). The capability itself (audit rows name the acting user, never anonymous) is unchanged and was never wrong.", "ref": "#9118" }, - { "revision": 5, "date": "2026-10-13", "change": "steps[9] and acceptance[8] (clause and verify) diff the two publish versions read off /history, and steps[8] and acceptance[7].verify say how history counts (assertion defect, #23166 A8; parked on #23121, carried by #23169). sys_metadata_history records draft saves and publishes alike, so ?from=1&to=2 compared the first draft save with its own publish (changed []); on QA run #23166's clean two-round run the published revisions were v2 and v4, and their diff named the edited widget key. The rollback steps are unchanged", "ref": "#23169" } + { "revision": 5, "date": "2026-10-13", "change": "steps[9] and acceptance[8] (clause and verify) diff the two publish versions read off /history, and steps[8] and acceptance[7].verify say how history counts (assertion defect, #23166 A8; parked on #23121, carried by #23169). sys_metadata_history records draft saves and publishes alike, so ?from=1&to=2 compared the first draft save with its own publish (changed []); on QA run #23166's clean two-round run the published revisions were v2 and v4, and their diff named the edited widget key. The rollback steps are unchanged", "ref": "#23169" }, + { "revision": 6, "date": "2026-10-16", "change": "steps[2] and acceptance[6] (clause, verify) re-pointed (checklist-accuracy finding 4 of QA run #23942, wave #23878). The Studio counter is no longer 'Changes · n' text but a numbered pill on the Changes button (objectui#12459), and the pill counts the drafts of the package in scope — an env-wide (package-less) dashboard draft is counted in none, so #23942 staged the browser leg's draft in a writable package. The step and clause name the pill and the writable-package staging, and the step says how to reach the draft in Studio — through an app of that package whose navigation names the dashboard, since a package with no app offers no dashboard to select (the re-run on #24036 found no chip until it staged one). Not loosened: a chip or pill that survives the publish, or is missing while the draft is pending, still FAILS", "ref": "#24036" } ] }, { @@ -542,7 +549,7 @@ "title": "The metadata type registry gates runtime writes: allowEnvironmentOverlay=false kinds refuse overlay (403 not_overridable), allowRuntimeCreate=false kinds refuse creation — both sides", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "api", "personas": ["admin"], @@ -602,7 +609,7 @@ ], "variants": [ "object (override locked, create open)", - "field (override locked, create open)", + "field (override locked, create LOCKED — the standalone runtime-create door was retired by the 2026-08-12 ruling, #7893: field declares allowRuntimeCreate:false, so PUT /api/v1/meta/field/OBJECT.NAME for a new name answers 403 NOT_CREATABLE; fields are authored inside the object)", "job (create locked — 403 not_creatable)", "view (override open)", "dashboard (override open)" @@ -619,7 +626,8 @@ ], "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": 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" } + { "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" }, + { "revision": 3, "date": "2026-10-16", "change": "variants[1] corrected (checklist-accuracy finding 1 of QA run #23942, wave #23878). The field variant still read 'create open', but DEFAULT_METADATA_TYPE_REGISTRY has declared field allowRuntimeCreate:false since #7893 (a standalone field row reached no object's fields), so its create side is locked like job's and answers 403 NOT_CREATABLE. The variant now states the expectation the registry declares; step[0] already tells the runner to read the flags first", "ref": "#24036" } ] }, { @@ -748,7 +756,7 @@ "title": "Permission-matrix editor UX: field-filter + bulk apply to EXACTLY the visible fields, and the Bulk column never clips at narrow widths", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "mixed", "personas": ["admin"], @@ -805,9 +813,9 @@ "evidence": "the narrow-viewport screenshot(s)" }, { - "clause": "the read-only gate holds: a read-only package/type hides Save and disables every bulk button + checkbox; the read-only badge names the reason (package vs type)", + "clause": "the read-only gate holds: a read-only set hides Save and disables every bulk button + checkbox; the read-only badge names WHICH gate holds", "oracle": "screenshot", - "verify": "on the read-only set Save is absent, bulk buttons are disabled, and the badge text distinguishes the package gate (engine.studio.pkg.readonly) from the type gate (perm.readOnly)", + "verify": "on the read-only set Save is absent, bulk buttons are disabled, and the badge names the gate that holds; the console distinguishes FOUR (objectui PermissionMatrixEditor.tsx at the pin): the package gate (engine.studio.pkg.readonly — Studio on a read-only package), the artifact gate (perm.readOnly.artifact, 'Read-only (this set is provided by a code package)' — a set a code package ships, in the metadata admin), the wall (perm.readOnly.walled — the security catalog under isolated/group tenancy) and the type gate (perm.readOnly — a type with no runtime write channel). On a stock boot only the package and artifact gates are reachable — the wall needs a walled tenancy posture and the type gate a permission type with no write channel, which stock does not have — so score those two on the packaged showcase_contributor set (Studio Access and the metadata admin) and record the other two as not reachable on stock. A badge that blames the wrong gate is the FAIL", "evidence": "the read-only screenshot" } ], @@ -836,6 +844,12 @@ "date": "2026-08-21", "change": "recorded on the item that its automation is pinned exclusively in the objectui repo and is therefore neither runnable nor pin-evidenced from this checkout — one of 23 items in that position, each of which every sweep had been re-deriving from scratch. The knownGap names the two honest ways to score it (hand-drive it as a manual run, or run the pin in an objectui checkout and cite the revision) and forbids the third: treating the `automated` field itself as coverage. The protocol and the exclusive-vs-mixed distinction live once in RUNNER.md; this line is the pointer (#10236 A5)", "ref": "#10236" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "acceptance[5] (clause, verify) re-pointed (checklist-accuracy finding 7 of QA run #23942, wave #23878). The clause distinguished only a package gate and a type gate, but the console names more read-only reasons — #23942 saw three (package, artifact, type) and the pin also carries the wall (objectui#12183) — and the type gate is unreachable on stock. The verify text now names each gate's key, the two reachable on stock (package in Studio Access, artifact in the metadata admin, both on showcase_contributor), and records the other two as not reachable on stock. Not loosened: Save must still be absent, every bulk control disabled, and the badge must name the right gate", + "ref": "#24036" } ] }, @@ -1035,7 +1049,7 @@ "title": "Source-authored page tiers render by the RIGHT path: kind:'html' source is PARSED-never-executed (static, in-browser sdui-parser compile), kind:'react' source is EXECUTED behind its capability gate (live data + interactivity) — command-center-jsx / capability-map (html) vs crm-workbench (react)", "since": "v16", "status": "active", - "revision": 2, + "revision": 3, "priority": "P2", "surface": "mixed", "personas": ["admin (seeded admin@objectos.ai) driving the published showcase app as an end user"], @@ -1058,7 +1072,7 @@ "prove the react tier is EXECUTED against LIVE data: the 'Total projects' KPI equals GET /api/v1/data/showcase_project's count (the page computes it via useAdapter().find('showcase_project'), reading the QueryResult .data envelope), and a showcase_project find query fires on the network — the number is computed at render, not authored", "prove react interactivity (executed handlers, not a parsed tree): click a ListView row → the ObjectForm binds THAT record (React useState); click '+ New project' → the form switches to create mode", "both-sides gate: on a boot with OS_PAGE_REACT=off, re-open crm-workbench — it must render the NAMED 'React pages are disabled on this deployment' notice (CapabilityDisabledNotice), never a blank and never an executed page; the html pages still render (html is not gated). If the env toggle cannot be flipped on the stock fixture, record this clause blocked(environment)", - "degradation: (a) html — save a faulting html source at the ACTIVE door (or publish one) and capture the 422 INVALID_METADATA with its compile diagnostics; then save the same source as a ?mode=draft draft and open the page designer's draft preview, where the 'HTML page failed to compile (N)' panel renders with the diagnostic messages — the save gate keeps a faulting html page off the rendered route, so the panel cannot be met there (knownGaps[0]); (b) react — a react runtime error shows the 'React page error' fallback on the rendered page. Neither may be a blank page" + "degradation: (a) html — save a faulting html source at the ACTIVE door (or publish one) and capture the 422 INVALID_METADATA with its compile diagnostics; then save the same source as a ?mode=draft draft IN A WRITABLE PACKAGE (?package=PKG) that also holds an app whose navigation references the page, and open it in Studio at /_console/studio/PKG/interfaces?surface=page:NAME, where the draft preview's 'HTML page failed to compile (N)' panel renders with the diagnostic messages. An env-wide (package-less) draft has no Studio surface, and the save gate keeps a faulting html page off the rendered route, so the panel cannot be met anywhere else (knownGaps[0]); (b) react — a react runtime error shows the 'React page error' fallback on the rendered page. Neither may be a blank page" ], "acceptance": [ { @@ -1119,7 +1133,8 @@ ], "history": [ { "revision": 1, "date": "2026-08-08", "change": "new item: the html (parse-never-execute, in-browser sdui-parser compile, static) vs react (executed at render, capability-gated, live data + interactivity) source-page tiers — the objectui renderer-path split grounded in PageRenderer's kind dispatch + react-page.tsx's gate, distinguishing command-center-jsx/capability-map (html) from crm-workbench (react); records the honest build-step finding (none needed to view — in-browser compile/transpile) as a knownGap", "ref": "claude/platform-test-checklist-ocwugl" }, - { "revision": 2, "date": "2026-10-13", "change": "acceptance[5] (clause and verify), steps[7] and knownGaps[0] name where an html compile error is observable (assertion defect, minor, #23166 A5; parked on #23121, carried by #23169). The save door compiles an html page's source against the deployment's manifest (runtime-authoring-gate.ts HTML_PAGE_SOURCE_COMPILE), so a faulting html page is refused 422 INVALID_METADATA at save / publish and never publishes, and the compile-error panel is reachable only in the designer's draft preview. QA run #23166 met the 422 and the draft-preview panel. The react half is unchanged", "ref": "#23169" } + { "revision": 2, "date": "2026-10-13", "change": "acceptance[5] (clause and verify), steps[7] and knownGaps[0] name where an html compile error is observable (assertion defect, minor, #23166 A5; parked on #23121, carried by #23169). The save door compiles an html page's source against the deployment's manifest (runtime-authoring-gate.ts HTML_PAGE_SOURCE_COMPILE), so a faulting html page is refused 422 INVALID_METADATA at save / publish and never publishes, and the compile-error panel is reachable only in the designer's draft preview. QA run #23166 met the 422 and the draft-preview panel. The react half is unchanged", "ref": "#23169" }, + { "revision": 3, "date": "2026-10-16", "change": "steps[7](a) names how to reach the draft preview (checklist-accuracy finding 8 of QA run #23942, wave #23878). The compile panel is reachable only for a page staged in a writable package that an app's navigation references, opened at /_console/studio/PKG/interfaces?surface=page:NAME; an env-wide draft has no Studio surface, which is what #23942 met. The clause is unchanged", "ref": "#24036" } ] }, { @@ -1127,7 +1142,7 @@ "title": "Studio Automations pillar on a read-only package: a packaged flow is NOT editable in place — New-flow and autosave honor the courtesy gate, the enable/disable switch produces no metadata write, and inspector edits are inert or announced, never silently discarded", "since": "v17", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "browser", "personas": ["admin (opens Studio on the read-only package)"], @@ -1138,7 +1153,7 @@ "a vendored console matching .objectui-sha — the pillar markup under test is objectui's, so a stale bundle tests yesterday's gate" ], "knownGaps": [ - "FIXED at objectui 1fb1ced36243 (objectui#11124), recorded so runs don't re-derive it: on a read-only package the Automations pillar's enable/disable switch is disabled (disabled={!isEditable || !!saving || readOnly}, its title the read-only hint) and toggleEnabled is guarded as well, so a forced click saves nothing; the flow node inspector receives the pillar's REAL readOnly flag, threaded as the Data pillar threads it, so its inputs are disabled; New-flow and autosave still honour the gate (StudioDesignSurface.tsx). QA run #23163 measured exactly that: the switch disabled with a reason title, a forced click sending nothing, the inspector inputs disabled, the flow byte-identical. The pre-fix shape — a clickable switch only the server's 422 refused, and inspector edits that took locally and vanished on reload — is gone; meeting it again is a regression FAIL, not a standing finding", + "FIXED at objectui 1fb1ced36243 (objectui#11124), recorded so runs don't re-derive it: on a read-only package the Automations pillar's enable/disable switch is disabled (disabled={!isEditable || !!saving || readOnly}, its read-only reason carried by aria-describedby and a tooltip since objectui#12303, not a `title`) and toggleEnabled is guarded as well, so a forced click saves nothing; the flow node inspector receives the pillar's REAL readOnly flag, threaded as the Data pillar threads it, so its inputs are disabled; New-flow and autosave still honour the gate (StudioDesignSurface.tsx). QA run #23163 measured exactly that: the switch disabled with a reason title, a forced click sending nothing, the inspector inputs disabled, the flow byte-identical. The pre-fix shape — a clickable switch only the server's 422 refused, and inspector edits that took locally and vanished on reload — is gone; meeting it again is a regression FAIL, not a standing finding", "objectui now pins the read-only posture: packages/app-shell/src/views/studio-design/StudioDesignSurface.automationsReadOnly-11124.test.tsx ('disables the header \"Enabled\" switch, and a click on it saves nothing'; 'opens the flow inspector read-only: the node inputs are disabled'; no canvas edit handles). It lives in the objectui repo only, so from this checkout the item stays browser-driven end to end; run the pin from an objectui checkout at the .objectui-sha revision to cite it beside the live read" ] }, @@ -1147,10 +1162,10 @@ "pin the precondition: GET /api/v1/packages must report com.example.showcase writable:false — the pillar's readOnly gate is pkgWritable===false (objectui StudioDesignSurface.tsx) and an UNKNOWN writability (fetch failed / still loading) deliberately leaves the pillar ungated, so a race here fakes a gate failure (hydration-race)", "baseline the server truth: GET /api/v1/meta/flow/showcase_urgent_task_alert (full body) — every later clause diffs against this read", "affordance sweep with the network panel recording: confirm the New-flow button is ABSENT from the rail heading; select the packaged flow and wait past the autosave debounce after touching the canvas — no flow save may leave the browser", - "click the enable/disable switch in the pillar header and capture everything: whether it is disabled/absent (at objectui 1fb1ced36243 it is disabled, titled with the read-only hint), whether a forced click fires any request (it must not — were it to, the request would be a flow DRAFT save flipping status active↔obsolete, StudioDesignSurface.tsx toggleEnabled), and what the UI surfaces", + "click the enable/disable switch in the pillar header and capture everything: whether it is disabled/absent (it is disabled, and its read-only reason rides aria-describedby and a tooltip — objectui#12303 — not a `title`), whether a forced click fires any request (it must not — were it to, the request would be a flow DRAFT save flipping status active↔obsolete, StudioDesignSurface.tsx toggleEnabled), and what the UI surfaces", "select a flow node and edit a property in the inspector; record whether the edit visibly takes on the canvas/draft, and whether any affordance announces a read-only or will-not-persist state", "reload the pillar (re-open the package) and re-inspect: record whether the inspector edit survived or vanished, and whether its discard was ever announced", - "close out: GET /api/v1/meta/flow/showcase_urgent_task_alert again and diff against the baseline — byte-identical" + "forge the write the client gate withholds, both spellings: PUT /api/v1/meta/flow/showcase_urgent_task_alert?mode=draft&package=com.example.showcase and the same PUT without ?package=, each with a modified body — capture each status and error.code. Then close out: GET /api/v1/meta/flow/showcase_urgent_task_alert again and diff against the baseline — byte-identical" ], "acceptance": [ { @@ -1160,16 +1175,16 @@ "evidence": "the rail DOM read + the flow-write-free network trace over the debounce window" }, { - "clause": "a packaged flow definition is not editable in place in Studio (ADR-0126 §2 Regime C, card F2): after driving every affordance the pillar offers, the flow's server-side metadata is byte-identical — and any write that slips past client courtesy is refused server-side with the ledgered 422 [writable_package_required] naming the writable-package remedy (ADR-0124 D1: the server is the authority, the client gate is courtesy)", + "clause": "a packaged flow definition is not editable in place in Studio (ADR-0126 §2 Regime C, card F2): after driving every affordance the pillar offers, the flow's server-side metadata is byte-identical — and any write that slips past client courtesy is refused server-side with a 403 — error.code ITEM_LOCKED when the write names the read-only package (?package=com.example.showcase: the named read-only base), NOT_OVERRIDABLE when it does not (flow declares allowEnvironmentOverlay:false) — each naming the clone or toggle remedy (ADR-0124 D1: the server is the authority, the client gate is courtesy)", "oracle": "api", - "verify": "GET /api/v1/meta/flow/showcase_urgent_task_alert before/after the whole probe: identical; every flow write captured in the trace answers 4xx with the writable_package_required class — a 2xx on any of them is the hard FAIL", + "verify": "GET /api/v1/meta/flow/showcase_urgent_task_alert before/after the whole probe: identical; every flow write captured in the trace, and the two forged in step 7, answer 403 with error.code ITEM_LOCKED (the write names com.example.showcase) or NOT_OVERRIDABLE (it names no package) — score the status and the code, never the message; a 2xx on any of them is the hard FAIL", "evidence": "the before/after meta reads, diffed + each refusal body" }, { "clause": "the enable/disable switch honors the read-only package lock exactly as the New-flow button does: clicking it produces NO metadata write", "oracle": "network", - "verify": "click the switch: the PASS shape is disabled/absent-or-inert (no request fires). At objectui 1fb1ced36243 the switch is disabled with the read-only hint as its title and toggleEnabled is guarded (objectui#11124), so even a forced click fires nothing — the expected-fail this clause carried (a clickable switch the server refused 422 writable_package_required) is fixed. A switch that fires a request is now a FAIL of this clause; a 2xx on that write escalates to clause 2's hard FAIL", - "evidence": "the click's network capture (no request) + the disabled switch's DOM state and title" + "verify": "click the switch: the PASS shape is disabled/absent-or-inert (no request fires). The switch is disabled — its read-only reason on aria-describedby and a tooltip (objectui#12303), not a `title` — and toggleEnabled is guarded (objectui#11124), so even a forced click fires nothing; the expected-fail this clause once carried (a clickable switch only the server refused) is fixed. A switch that fires a request is now a FAIL of this clause; a 2xx on that write escalates to clause 2's hard FAIL", + "evidence": "the click's network capture (no request) + the disabled switch's DOM state and its accessible description" }, { "clause": "inspector edits are inert OR their discard is announced: the flow node inspector on a read-only package either disables its inputs or tells the author the edit will not persist — an edit that visibly takes and silently vanishes on reload is the failure", @@ -1186,14 +1201,16 @@ "traps": ["stale-console-bundle", "hydration-race", "automation-input"], "source": [ "objectui packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx (readOnly = pkgWritable===false; unknown writability deliberately leaves the pillar ungated), (the prop's own contract: 'hide/disable flow-authoring affordances'), (autosave blocked on readOnly), (the enable/disable switch — disabled={!isEditable || !!saving || readOnly} and a guarded toggleEnabled since objectui#11124), (toggleEnabled = client.save flow draft flipping status active↔obsolete), (New-flow gated on !readOnly), (the inspector receives the real readOnly flag since objectui#11124, as the Data pillar's editors do), (the rail lists package-scoped flows) + StudioDesignSurface.automationsReadOnly-11124.test.tsx (the pin) — objectui owns this pillar's markup, so locator and gate fixes land there (ADR-0054)", - "packages/metadata-protocol/src/sys-metadata-repository.ts + packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts (the 422 [writable_package_required] refusal a read-only code package answers to metadata writes — the server half of the lock; com.example.showcase is source-loaded read-only)", + "packages/metadata-protocol/src/sys-metadata-repository.ts + packages/metadata-protocol/src/sys-metadata-repository.package-writability.test.ts (a read-only code package's write refusals — the server half of the lock; com.example.showcase is source-loaded read-only. The test pins 'a read-only base answers ITEM_LOCKED / 403 where a writable base answers NOT_OVERRIDABLE / 403')", "docs/adr/0126-packaged-metadata-customization-model.md §2/§3 (flow = Regime C: locked base, in-place edit refused loudly at the write door — card F2)", + "packages/metadata-protocol/src/sys-metadata-repository.ts#readOnlyBaseOverrideError (the 403 ITEM_LOCKED a write naming a read-only base answers) + packages/metadata-protocol/src/protocol.ts (the 403 NOT_OVERRIDABLE an overlay write on a non-overlayable type answers when no package is named)", "#12438 (the ADR-0126 packaged flow/action sweep this item lands from)", "access-security.readonly-package-locks-studio (object designer + permission matrix — cross-referenced, not widened), access-security.packaged-flow-write-door-parity (the API-side door parity for the same packaged flow), automation.setup-packaged-automation-board (the ledgered enable/disable surface — a different mechanism from this pillar's status flip)" ], "history": [ { "revision": 1, "date": "2026-08-26", "change": "new item (#12438 card F2, UI half): the Studio Automations pillar's read-only package lock — the promise (no in-place edit, switch produces no metadata write, inspector edits inert or announced) asserted with the current source shape recorded as the expected finding: the switch is clickable with only the server-side 422 behind it (disabled expression omits readOnly, StudioDesignSurface.tsx) and the inspector hardcodes readOnly:false so edits take locally and silently vanish on reload; New-flow and autosave honor the gate. Grounded in objectui f7c52e2 + the framework's writable_package_required door; owns the Automations pillar only, cross-referencing readonly-package-locks-studio for the surfaces it already owns", "ref": "#12438" }, - { "revision": 2, "date": "2026-10-13", "change": "acceptance[2] and [3] (verify; evidence for [2]) drop the expected-fail wording, with knownGaps[0] and [1], steps[4] and source[0] (stale spec, #23163 A2 / A3; parked on #23121, carried by #23169). objectui#11124 disabled the pillar's switch on a read-only package (with the read-only hint as its title, and a guarded toggleEnabled) and threaded the real readOnly flag into the flow node inspector, pinned by StudioDesignSurface.automationsReadOnly-11124.test.tsx. QA run #23163 measured the switch disabled, a forced click sending nothing, the inspector inputs disabled and the flow byte-identical. Re-read from objectui source at 1fb1ced36243, still this repo's .objectui-sha. The clauses' promises are unchanged; only the recorded finding flips to the fixed shape", "ref": "#23169" } + { "revision": 2, "date": "2026-10-13", "change": "acceptance[2] and [3] (verify; evidence for [2]) drop the expected-fail wording, with knownGaps[0] and [1], steps[4] and source[0] (stale spec, #23163 A2 / A3; parked on #23121, carried by #23169). objectui#11124 disabled the pillar's switch on a read-only package (with the read-only hint as its title, and a guarded toggleEnabled) and threaded the real readOnly flag into the flow node inspector, pinned by StudioDesignSurface.automationsReadOnly-11124.test.tsx. QA run #23163 measured the switch disabled, a forced click sending nothing, the inspector inputs disabled and the flow byte-identical. Re-read from objectui source at 1fb1ced36243, still this repo's .objectui-sha. The clauses' promises are unchanged; only the recorded finding flips to the fixed shape", "ref": "#23169" }, + { "revision": 3, "date": "2026-10-16", "change": "acceptance[1] (clause, verify), acceptance[2] (verify, evidence), steps[4] and [7], knownGaps[0] and source re-pointed (checklist-accuracy finding 5 of QA run #23942, wave #23878). Clause 1 named the server refusal as 422 [writable_package_required]; the forged flow write is refused 403 — ITEM_LOCKED when it names com.example.showcase, NOT_OVERRIDABLE when it names no package (sys-metadata-repository.package-writability.test.ts pins the pair) — so step 7 now forges both spellings and the clause scores status and code. The switch's reason is an aria-describedby plus tooltip since objectui#12303, not a `title`. Not loosened: a 2xx on any flow write is still the hard FAIL, and a 403 with any other code fails the clause", "ref": "#24036" } ] }, { @@ -1201,15 +1218,15 @@ "title": "Packaged views and dashboards are 'yours to edit directly' (ADR-0126 Regime O): the designer live-edits the PACKAGED artifact, publish reaches the end user, reset-overlay returns the shipped base — while a Regime-C flow in the same surface stays locked", "since": "v17", "status": "active", - "revision": 1, + "revision": 2, "priority": "P2", "surface": "browser", "personas": ["admin (authors the overlay in the designer)", "end user (renders the published change)"], "fixtures": { "app": "showcase", "requires": [ - "the stock packaged showcase views (com.example.showcase — e.g. the showcase_task view gallery, examples/app-showcase/src/ui/views/task.view.ts); discover the container name via GET /api/v1/meta/view?object=showcase_task rather than guessing it", - "the built-in System Overview dashboard (name system_overview), code-shipped by @objectstack/platform-objects — packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts", + "the stock packaged showcase views (com.example.showcase — e.g. the showcase_task view gallery, examples/app-showcase/src/ui/views/task.view.ts). The showcase ships INDIVIDUAL views, not a container: GET /api/v1/meta/view?object=showcase_task lists them by qualified name (e.g. showcase_task.default) — pick one from that list rather than guessing a name", + "the built-in System Overview dashboard (name system_overview), defined in @objectstack/platform-objects (packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts) and REGISTERED by plugin-auth (packages/plugins/plugin-auth/src/auth-plugin.ts, dashboards: [SystemOverviewDashboard]) — so its layered read reports packageId com.objectstack.plugin-auth", "a packaged showcase flow as the same-surface Regime-C negative (e.g. showcase_urgent_task_alert)", "a vendored console matching .objectui-sha" ], @@ -1220,14 +1237,14 @@ ] }, "steps": [ - "boot the showcase, sign in admin@objectos.ai/admin123; open the metadata-admin designer on a PACKAGED showcase view (/_console/…/metadata/view/, container discovered via GET /api/v1/meta/view?object=showcase_task) — it must open EDITABLE, with no 'provided by an installed package' banner", - "record the shipped base: GET /api/v1/meta/view?object=showcase_task and the layered read of the container, before any edit — the reset clause diffs against this", + "boot the showcase, sign in admin@objectos.ai/admin123; open the metadata-admin designer on a PACKAGED showcase view (/_console/…/metadata/view/VIEW, VIEW picked from GET /api/v1/meta/view?object=showcase_task — e.g. showcase_task.default; the showcase ships individual views, not a container) — it must open EDITABLE, with no lock banner", + "record the shipped base: GET /api/v1/meta/view?object=showcase_task and the layered read of the picked view (GET /api/v1/meta/view/VIEW/layers), before any edit — the reset clause diffs against this", "edit DIRECTLY on the packaged artifact — change the default list's label and add/remove a column — save draft and publish through the designer chrome", "as an end user, open the showcase task list (client reload only, no server restart): the edited label/column set renders", "server truth: GET the view again — the overlay body is served over the shipped base (overlay wins at read; the code layer remains underneath in the layered read)", "dashboard leg: open the designer on the built-in system_overview dashboard — it opens EDITABLE the same way (dashboard allowEnvironmentOverlay:true); edit a widget title; publish; open the rendered System Overview board and confirm the edited title renders (its live-count semantics stay dashboards.system-overview-live-counts' business)", "reset BOTH overlays via the designer's destructive control: it must carry the 'Reset overlay' verb (resettable:true on the layered envelope — a package baseline exists), confirm; the follow-up GET serves the SHIPPED body again and the end-user surfaces render the base", - "negative, same surface: open the designer route on the PACKAGED flow — it opens read-only behind the installed-package banner (artifact-backed + flow allowEnvironmentOverlay:false) with no operating save/publish path" + "negative, same surface: open the designer route on the PACKAGED flow — it opens read-only behind the lock banner (at the pin it reads 'This item is locked and cannot be edited or deleted.' and names the owning package com.example.showcase; artifact-backed + flow allowEnvironmentOverlay:false) with no operating save/publish path" ], "acceptance": [ { @@ -1243,7 +1260,7 @@ "evidence": "before/after end-user screenshots + server log excerpt" }, { - "clause": "the dashboard leg holds on a BUILT-IN packaged dashboard: system_overview (code-shipped by platform-objects) is directly editable the same way, and the rendered board shows the published edit ('… and dashboards are yours to edit directly')", + "clause": "the dashboard leg holds on a BUILT-IN packaged dashboard: system_overview (defined in platform-objects, registered by plugin-auth's package com.objectstack.plugin-auth) is directly editable the same way, and the rendered board shows the published edit ('… and dashboards are yours to edit directly')", "oracle": "api", "verify": "the dashboard editor opens editable; after publish GET /api/v1/meta/dashboard/system_overview serves the edited widget title and the rendered board displays it — the tiles' live-count truthfulness is dashboards.system-overview-live-counts, cite it rather than re-proving", "evidence": "the dashboard meta read + the rendered-board screenshot" @@ -1255,9 +1272,9 @@ "evidence": "the reset receipts + post-reset reads diffed against the step-2 baseline + the reverted renders" }, { - "clause": "a Regime-C artifact in the SAME surface stays locked: the packaged flow opens read-only behind the installed-package banner with no operating save/publish path — 'yours to edit directly' is the Regime-O display classes (view, dashboard), never flow (ADR-0126 §3)", + "clause": "a Regime-C artifact in the SAME surface stays locked: the packaged flow opens read-only behind the lock banner with no operating save/publish path — 'yours to edit directly' is the Regime-O display classes (view, dashboard), never flow (ADR-0126 §3)", "oracle": "dom", - "verify": "after a screenshot confirms render, the flow editor shows the installed-package lock banner and its inputs are read-only (isArtifactItem + flow allowEnvironmentOverlay:false ⇒ canWrite false); no save/publish affordance operates", + "verify": "after a screenshot confirms render, the flow editor shows the lock banner naming the owning package (its wording is the console's and is not scored) and its inputs are read-only (isArtifactItem + flow allowEnvironmentOverlay:false ⇒ canWrite false); no save/publish affordance operates", "evidence": "the locked-editor screenshot + the disabled-state DOM read" } ], @@ -1278,7 +1295,8 @@ "studio-authoring.org-override-registry-gate (the raw-PUT registry-gate half — cross-referenced, not duplicated), studio-authoring.view-authoring-live (the non-packaged authoring loop this item complements), dashboards.system-overview-live-counts (the same board's live-count semantics — and the reason the overlay must be reset)" ], "history": [ - { "revision": 1, "date": "2026-08-26", "change": "new item (#12438, E1 tier-1 claim / ADR-0126 §3 Regime O): 'views and dashboards are yours to edit directly' proven on PACKAGED artifacts through the designer — live edit, publish, end-user render, reset-overlay back to the shipped base — with a built-in dashboard leg (system_overview; the dashboard kind's allowOrgOverride:true overlay door verified in the registry at metadata-plugin.zod.ts before asserting, per the register's instruction) and a same-surface Regime-C flow negative. Complements org-override-registry-gate (raw-PUT half) and view-authoring-live (which deliberately avoids packaged views)", "ref": "#12438" } + { "revision": 1, "date": "2026-08-26", "change": "new item (#12438, E1 tier-1 claim / ADR-0126 §3 Regime O): 'views and dashboards are yours to edit directly' proven on PACKAGED artifacts through the designer — live edit, publish, end-user render, reset-overlay back to the shipped base — with a built-in dashboard leg (system_overview; the dashboard kind's allowOrgOverride:true overlay door verified in the registry at metadata-plugin.zod.ts before asserting, per the register's instruction) and a same-surface Regime-C flow negative. Complements org-override-registry-gate (raw-PUT half) and view-authoring-live (which deliberately avoids packaged views)", "ref": "#12438" }, + { "revision": 2, "date": "2026-10-16", "change": "fixtures.requires[0] and [1], steps[0], [1] and [7] and acceptance[2] (clause) and [4] (clause, verify) corrected (checklist-accuracy finding 6 of QA run #23942, wave #23878). The showcase ships individual views, not a container, so the 'discover the container name' instruction pointed at nothing — the item now picks a packaged view from the list and reads its /layers. The flow's banner is the lock banner ('This item is locked and cannot be edited or deleted.' plus the owning package), not an 'installed-package' banner; its wording is recognised, not scored. system_overview is defined in platform-objects but registered by plugin-auth, which is why its /layers reports com.objectstack.plugin-auth. Not loosened: the flow must still open locked with no operating save path", "ref": "#24036" } ] }, { @@ -1286,7 +1304,7 @@ "title": "The metadata governance sweep: GET /meta/diagnostics reports every STORED item failing spec validation with located errors, refuses outages and bad spellings loudly instead of answering '0 problems', and the Studio diagnostics page + directory tile badges paint the same numbers", "since": "v16", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "mixed", "personas": ["admin (the governance surface is Studio's)"], @@ -1323,7 +1341,7 @@ { "clause": "stats tell the directory's truth: per sampled type, stats[type].count equals the type's own list length and stats[type].packages equals the sorted distinct _packageId set — the one-round-trip aggregate the Studio directory tiles render from", "oracle": "api", - "verify": "side-by-side of stats[] against the GET /meta/ list for each sampled type (count, packages). `locked` counts the items whose served READ ENVELOPE reports a lock other than 'none' — the derivation GET /api/v1/meta// publishes (protocol.ts servedLockState, #21694) — NOT the rows whose declared `_lock` != 'none': a packaged base the write doors refuse reads locked on its envelope while declaring no `_lock`, so compare `locked` against each sampled item's envelope lock", + "verify": "side-by-side of stats[] against the GET /meta/ list for each sampled type (count, packages). `locked` counts the items whose served READ ENVELOPE reports a lock other than 'none' — the derivation the LAYERED read publishes — read it from GET /api/v1/meta///layers, whose envelope carries `lock` (protocol.ts servedLockState, #21694); the default GET /api/v1/meta// is the cached arm and carries no `lock` by design, so it cannot answer this — NOT the rows whose declared `_lock` != 'none': a packaged base the write doors refuse reads locked on its envelope while declaring no `_lock`, so compare `locked` against each sampled item's envelope lock", "evidence": "the stats excerpt + the list reads" }, { @@ -1387,7 +1405,8 @@ "history": [ { "revision": 1, "date": "2026-08-30", "change": "new item (2026-08-30 sweep, angle 4): the governance sweep GET /meta/diagnostics + its two Studio surfaces had no coverage — nothing asserted the stats/tile parity seam, the #8924 spelling refusal (whose regression the page paints as 'All clear'), the #8855 outage honesty, or the clean-store empty state. Grounded against source with two corrections to the register hypothesis: the scoping query param is ?package= (not ?packageId= — that is the protocol-layer argument name), and severity=warning cannot 'add warning-only entries' on stock because no producer emits warnings at head (reserved for a future lint layer) — authored as a mechanism clause with the lint-layer tripwire recorded. The invalid-stored-row positive is blocked(fixture) by construction (save doors validate; sys_metadata is not a data-plane object), with the stale-row snapshot named as the honest fixture candidate", "ref": "#sweep-2026-08-30" }, { "revision": 2, "date": "2026-10-06", "change": "partial-coverage gap of QA run #21784 parked on #22017 (row 17). A7 was blocked(fixture): an invalid stored row cannot be planted, because every authoring door validates at save. Disposition: recorded knownGap. The leg cannot be reached on a stock environment by design. The knownGap now names the environment it needs (a store already holding a stale row), says no CI job builds one, and points at the producer-level pin as mechanism-only. No clause changed", "ref": "#22017" }, - { "revision": 3, "date": "2026-10-13", "change": "acceptance[1].verify compares stats.locked against the served envelope lock (stale spec, #23166 A1; parked on #23121, carried by #23169). getMetaDiagnostics counts an item as locked when its read envelope reports a lock other than 'none' — the same derivation getMetaItem publishes (#21694) — not when its declared _lock is set. QA run #23166 compared against the envelope lock and passed. Read from source at 6d7f4df3c (protocol.ts getMetaDiagnostics docblock and loop). The A6 row of the same run, the unplantable invalid stored row, is already recorded in full in knownGaps[0] and needs no edit", "ref": "#23169" } + { "revision": 3, "date": "2026-10-13", "change": "acceptance[1].verify compares stats.locked against the served envelope lock (stale spec, #23166 A1; parked on #23121, carried by #23169). getMetaDiagnostics counts an item as locked when its read envelope reports a lock other than 'none' — the same derivation getMetaItem publishes (#21694) — not when its declared _lock is set. QA run #23166 compared against the envelope lock and passed. Read from source at 6d7f4df3c (protocol.ts getMetaDiagnostics docblock and loop). The A6 row of the same run, the unplantable invalid stored row, is already recorded in full in knownGaps[0] and needs no edit", "ref": "#23169" }, + { "revision": 4, "date": "2026-10-16", "change": "acceptance[1].verify names the read that carries the envelope lock (checklist-accuracy finding 2 of QA run #23942, wave #23878). It told the runner to compare stats.locked against the lock GET /api/v1/meta/TYPE/NAME publishes, but that default read is the cached arm and carries no `lock` by design; the envelope lock is on GET /api/v1/meta/TYPE/NAME/layers, which is where #23942 read it (object 107, view 0, flow 32 locked, matching stats). The comparison itself is unchanged", "ref": "#24036" } ] }, { @@ -1395,7 +1414,7 @@ "title": "A doc on the app menu follows the reader's audience: an admin authors a doc and puts it (or its book) on an app menu; a member who may read it sees it and opens it in its book; a member outside its audience does not see the entry and gets a refusal — never \"not found\", never a blank page — on the direct route", "since": "v17.5", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "mixed", "personas": [ @@ -1413,7 +1432,7 @@ ], "knownGaps": [ "THE STOCK SHOWCASE CORPUS CARRIES NO { permissionSet } BOOK AND NO doc NAV ENTRY — verify first (GET /api/v1/meta/book, read each audience; GET /api/v1/meta/app?id=showcase_app, look for type:'doc'). The run authors the books, docs, permission set and nav entries at runtime and tears them down, as platform-core.docs-audience-gate allows; the audience is read off the stored row, so a runtime-authored book exercises the same resolver", - "FORK WINDOW: while .objectui-sha predates objectui PR 11241 / PR 11258, the vendored console carries the PRE-feature UI — at db11afd496 the console source holds no 'You do not have access to this documentation' heading and no renderer doc-target check (source read) — so a run against it fails for a reason that is not this item's. Drive a console built from an objectui revision carrying both (RUNNER.md's two honest options) and NAME that revision in the run record, beside the pin", + "FORK WINDOW CLOSED: .objectui-sha (daeb77fc84dd and later, f8267df93aba today) carries objectui PR 11241 and PR 11258 — the 'You do not have access to this documentation' refusal page and the menu pruning — so the vendored console is the right one to drive (QA run #23942 ran outside the window). Only a console older than those PRs (db11afd496 held neither, source read) fails for a reason that is not this item's; record the console revision in the run record as usual", "FIXTURE RECIPE, measured on the first run: the showcase is invite_only, so members come from POST /api/v1/auth/admin/create-user (mustChangePassword false), not sign-up; the Studio doc editor no longer refuses the read-only code package with a 422 — at objectui 1fb1ced36243 it saves an ENV-WIDE draft and offers no package picker (QA run #23166), so a doc authored through the editor lands outside the package that holds the books. Install a writable base first (POST /api/v1/packages with id, name, version, type 'app') and drive every book, doc and the app into it over the metadata API with ?package=, so books and docs share one package; the seeded admin holds no app-declared set, so once the gated book claims the doc the admin's own editor and /meta/app answer the audience refusal too — author the doc before the gated book, or read it back as the reader", "REPO OWNERSHIP (ADR-0054): the server half — /meta/app pruning, /meta/doc and /meta/book refusals — is objectstack's; the menu markup, the direct-route refusal page and the claiming-book permalink are objectui's. A locator or copy fix goes to objectui" ] @@ -1474,7 +1493,8 @@ ], "history": [ { "revision": 1, "date": "2026-09-30", "change": "new — the 清单项待写 row of docs/NORTH-STAR.md beside platform-core.docs-audience-gate and platform-core.docs-portal-render; drafted with objectui#10188 and corrected by the at-tier review of objectui PR 11258 (the UI-mirror clause scoped to entries naming an installed doc or book; no signed-out persona — /docs sits behind ProtectedRoute)", "ref": "#20939" }, - { "revision": 2, "date": "2026-10-13", "change": "knownGaps[2]'s fixture recipe re-pointed (knownGap stale, #23166 A0; parked on #23121, carried by #23169). The Studio doc editor no longer answers the read-only showcase package with a 422: it saves an env-wide draft and offers no package picker, so the recipe's reason for a writable base changed, not its need. The books and docs must share one package, so the recipe drives the writable base over the metadata API with ?package=. QA run #23166 did exactly that and passed", "ref": "#23169" } + { "revision": 2, "date": "2026-10-13", "change": "knownGaps[2]'s fixture recipe re-pointed (knownGap stale, #23166 A0; parked on #23121, carried by #23169). The Studio doc editor no longer answers the read-only showcase package with a 422: it saves an env-wide draft and offers no package picker, so the recipe's reason for a writable base changed, not its need. The books and docs must share one package, so the recipe drives the writable base over the metadata API with ?package=. QA run #23166 did exactly that and passed", "ref": "#23169" }, + { "revision": 3, "date": "2026-10-16", "change": "knownGaps[1] closed (checklist-accuracy finding 9 of QA run #23942, wave #23878): the .objectui-sha pin has been past the fork window since daeb77fc84dd — the console carries the refusal page and the menu pruning — so the knownGap no longer asks for a console built elsewhere. Wording only: no step, request or oracle changed", "ref": "#24036" } ] }, { diff --git a/docs/qa/platform-checklist/areas/ux-conventions.json b/docs/qa/platform-checklist/areas/ux-conventions.json index e9b0ecd5ce..5ac64ea311 100644 --- a/docs/qa/platform-checklist/areas/ux-conventions.json +++ b/docs/qa/platform-checklist/areas/ux-conventions.json @@ -9,7 +9,7 @@ "title": "Every user-initiated write answers visibly — success names what changed, a refusal says why, and a no-op claims nothing", "since": "v17.7", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "browser", "personas": [ @@ -25,19 +25,19 @@ ], "knownGaps": [ "FIXED, regression row, expected PASS (objectui#11817): Setup → Users → Invite User toasts 'Invitation sent to EMAIL' — the declared successMessage 'Invitation sent to ${result.email}' (packages/platform-objects/src/identity/sys-user.object.ts#invite_user) interpolating the invitee from the action result — while the sys_invitation and sys_email rows are written (QA run #23163 at objectui 1fb1ced36243; pin ObjectView.inviteFeedback-11817.test.tsx)", - "FIXED, regression row, expected PASS (objectui#11807): Studio Publish now gives a success confirmation (QA run #23163 at objectui 1fb1ced36243). Expected FAIL on stock, filed by that run and not to be re-filed: inline-edit 'Save All' on the task grid and Studio's 'Discard draft' land their write with no feedback at all (objectstack-ai/objectui#12303), and the approvals drawer's Approve toasts a bare 'Approved.' — the framework-declared successMessage on sys_approval_request (#23157)", + "FIXED, regression rows, expected PASS (QA run #23953 at objectui daeb77fc84dd): Studio Publish gives a success confirmation (objectui#11807); the task grid's 'Save All' toasts '1 row saved' (objectui#12303 fixed for this row); the approvals drawer's Approve toasts the framework-declared successMessage 'Approved \"${result.request.record_title}\"' (packages/plugins/plugin-approvals/src/sys-approval-request.object.ts), e.g. Approved \"EXP-2001\" (#23157, closed). Studio's 'Discard draft' now gives feedback too, but the toast and its confirm name the raw type/name — 'Draft discarded: object/NAME' (objectui ResourceEditPage.tsx, key engine.edit.draftDiscarded, unchanged at f8267df93aba) — while the item has a label: expected FAIL on acceptance[1] for that row, filed as objectstack-ai/objectui#12690 and not to be re-filed", "FIXED, regression row, expected PASS (objectui#11816): leaving an inline editor with no value change no longer reads '1 row modified · Save All (1)' (QA run #23163 at objectui 1fb1ced36243; pin data-table-noop-edit-11816.test.tsx) — negative[1]", "Undo is scored only where a surface offers it, and on stock NONE does: at objectui 1fb1ced36243 only an action declaring `undoable` pushes an Undo op (inline and form edits push none), and the showcase declares no undoable action (records-forms.record-edit-undo, blocked(fixture)). So acceptance[5] scores blocked(fixture) on stock (QA run #23163 A5). Its absence elsewhere is recorded in the evidence and is never a FAIL of this item: which writes should offer Undo is a product decision, not this item's oracle" ] }, "variants": [ "record create — New form save on showcase_task", - "record inline edit — Edit inline → change Priority → save the row", + "record inline edit — double-click a Priority cell on the task grid (the grid has no 'Edit inline' button), change it, then Save row or Save All", "bulk delete — select two rows → Delete in the selection bar → confirm", "kanban move — drag a card Backlog → To Do (direct manipulation: the card in its new column IS the success feedback; a refused move must revert with an error)", "declared record action — Mark Done on a task record (declared successMessage 'Task marked done.', examples/app-showcase/src/ui/actions/index.ts#MarkDoneAction)", "param action dialog — Setup → Users → Invite User → Confirm (declared successMessage 'Invitation sent to ${result.email}')", - "approval decision — Approvals inbox → Approve with a comment", + "approval decision — the decision drawer on the approvals page, /_console/apps/com.example.showcase/system/approvals (the 'Approvals inbox' link opens the review-queue page, which is not where the decision is taken) → Approve with a comment", "import — Tasks → Import a two-row CSV → the result screen", "Studio autosave — rename a field label in the scratch package", "Studio publish — Publish the scratch package", @@ -74,7 +74,7 @@ { "clause": "a refused write shows an error that says why — next to the field or in an error toast — and never a success message", "oracle": "network", - "verify": "negative (a): the request answers 4xx, the UI shows the server's reason, and no success toast appears in the same capture window", + "verify": "negative (a) has two legitimate refusal shapes, and the evidence records which one occurred: the request answers 4xx and the UI shows the server's reason; or the console blocks the submit itself — no write request leaves the browser — and shows the reason at the field (e.g. 'Title is required') or in an error toast (QA run #23953 met this shape for the required-field case). Either way no success toast appears in the same capture window. A write request answering 2xx, a refusal that shows no reason, or any success feedback is a FAIL", "evidence": "request status and error code, the error text shown, screenshot" }, { @@ -117,6 +117,12 @@ "date": "2026-10-13", "change": "knownGaps[0]-[3], variants[5] and [10], steps[5] and acceptance[5].verify (knownGaps stale and a fixture gap, #23163 write-feedback / A5; parked on #23121, carried by #23169). objectui#11817, objectui#11807 and objectui#11816 are all fixed at objectui 1fb1ced36243, still this repo's .objectui-sha: their knownGaps become regression rows, and the invite text is the declared 'Invitation sent to ${result.email}'. The same run's new feedback defects (objectui#12303 inline Save All and Studio Discard draft; #23157 the bare 'Approved.') are named so the next run cites rather than re-files them. Studio discard is driven on the metadata designer. Undo: no stock surface offers it, so acceptance[5] scores blocked(fixture), the same root as records-forms.record-edit-undo", "ref": "#23169" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "knownGaps[1], acceptance[3].verify and variants[1] and [6] corrected (checklist-accuracy notes 1–3 of QA run #23953, wave #23878). knownGaps[1] was stale: Save All toasts '1 row saved', Discard draft gives feedback and Approve toasts the declared 'Approved \"RECORD\"'; the Discard toast names the raw type/name, which is the open objectui#12690, so that row is an expected FAIL on clause 1 rather than 'no feedback'. acceptance[3].verify admitted only a 4xx, but the console refuses a required-field-empty submit in the browser and sends no request — a refusal that still says why, at the field — so the oracle now names both shapes and records which occurred. Not loosened: a 2xx write, a reasonless refusal or any success feedback still FAILS. The inline-edit and approval variants named surfaces that do not exist as written (no 'Edit inline' button; the inbox link opens the review queue)", + "ref": "#24036" } ] }, @@ -125,7 +131,7 @@ "title": "Every disabled control explains itself — the reason shows on hover and on keyboard focus, and is exposed as an accessible description", "since": "v17.7", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "browser", "personas": [ @@ -140,7 +146,7 @@ ], "knownGaps": [ "FIXED, now the calibration reference (objectui#11811): the task record's Archive button (examples/app-showcase/src/ui/actions/index.ts#ArchiveTaskAction, disabled while has(record.done) && record.done != true) carries its reason as a tooltip AND an aria-describedby on a focusable wrapper, in both its record_header and record_section placements (objectui DeclaredActionsBar.tsx; pin DeclaredActionsBar.disabledReason-11811.test.tsx). The action spec has no author-facing reason next to `disabled`, so the reason is the generic platform fallback — record it as generic under clause 3. Check this control first to calibrate both the hover and the keyboard oracle", - "Studio's Publish button on the read-only showcase package calibrates HOVER ONLY: its reason is a `title` on a disabled button with no focusable wrapper, so Tab skips it and clause 2 fails for it. Expected FAIL on stock, filed by QA run #23163 and not to be re-filed (objectstack-ai/objectui#12303): Studio's read-only Field properties controls (Label input, Remove) are disabled with no reason at all, and Studio Publish and the import wizard's disabled Next carry reasons a keyboard cannot reach" + "Studio's Publish button on the read-only showcase package and the import wizard's disabled Next are FIXED for the keyboard (QA run #23953 at objectui daeb77fc84dd): each carries its reason on a focusable wrapper with an aria-describedby, so both now calibrate hover AND keyboard, expected PASS; Studio's automation Enabled toggle and the field inspector's Remove pass the same way. Expected FAIL on stock, not to be re-filed: the field inspector's read-only Label / API name / Type / Required / Move up-down show the panel tooltip on hover but are not in the tab order and carry no description (objectstack-ai/objectui#12303); and the Studio validation editor's controls on a read-only package (Type, Severity, rule name, label, message, status field, transitions, Runs on, Priority, Enabled) give no reason on hover or by keyboard (objectstack-ai/objectui#12691)" ] }, "variants": [ @@ -201,6 +207,12 @@ "date": "2026-10-13", "change": "knownGaps[0] and [1] (knownGap stale, #23163; parked on #23121, carried by #23169). objectui#11811 is fixed at objectui 1fb1ced36243, still this repo's .objectui-sha: Archive carries a tooltip and an aria-describedby on a focusable wrapper, so it becomes the PASS reference for both oracles. Studio Publish, the old calibration, passes hover only: its title sits on a disabled button with no focusable wrapper. The run's new defects (objectui#12303) are named so the next run cites rather than re-files them. No clause changed", "ref": "#23169" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "knownGaps[1] corrected (checklist-accuracy note 4 of QA run #23953, wave #23878). It said Studio Publish calibrates hover only and that Publish and the import wizard's Next carry reasons a keyboard cannot reach; at objectui daeb77fc84dd both are keyboard-reachable through a focusable wrapper with an aria-describedby, so they calibrate both oracles. The expected-FAIL list keeps the field inspector's read-only inputs (objectui#12303) and adds the validation editor's read-only controls that #23953 extracted as objectui#12691. Wording only: no step, request or oracle changed", + "ref": "#24036" } ] }, @@ -209,7 +221,7 @@ "title": "Where a label exists users see the label — machine names, raw ids and spec paths appear only as secondary text", "since": "v17.7", "status": "active", - "revision": 2, + "revision": 3, "priority": "P1", "surface": "browser", "personas": [ @@ -223,7 +235,7 @@ "read access to the served metadata of every sampled surface — labels are read from metadata, never assumed" ], "knownGaps": [ - "objectui#11862, PARTLY fixed at objectui 1fb1ced36243 (QA run #23163). Expected PASS now: Explain access's principal line names the person, not the raw user id (pin AccessExplainPanel.principalPerson-11862.test.tsx), and the nav editor's items. Expected FAIL on stock, still objectui#11862 (do not re-file): the Access permission-set list shows a new set by its name ('qa_readers', while the detail header reads its label), the Changes panel lists machine names ('PERMISSION · 1 | qa_readers'), and the Lookup inspector's Display-field picker puts the api_name first with the label grey and truncated. ⚠ Explain's Evaluation pipeline › Principal resolution prose still names the user by raw id: that sentence is built server-side (packages/plugins/plugin-security/src/explain-engine.ts) and is #23157, not objectui#11862", + "objectui#11862 is FIXED for the rows that still failed at 1fb1ced36243 (QA run #23953 at objectui daeb77fc84dd): the Access permission-set list, the Changes panel and the Lookup inspector's Display-field picker now lead with the label, as do Explain access's principal line (pin AccessExplainPanel.principalPerson-11862.test.tsx) and the nav editor's items — regression rows, expected PASS. Explain's server-built Principal resolution prose names the person too — 'Principal NAME (EMAIL; id USER_ID)' (packages/plugins/plugin-security/src/explain-engine.ts; #23157, closed). Expected FAIL on stock, not to be re-filed (objectstack-ai/objectui#12693): (a) Explain access's principal card renders its position and permission-set chips by machine name (org_member, showcase_member_default) and the result banner names the object by its name, while labels exist; (b) Studio Interfaces' list-view 'Columns' chips render field api names (title, owner_ref)", "FIXED, regression row, expected PASS (objectui#11866): Profile states the access the console resolves rather than the auth library's raw user.role ('user') (objectui apps/console/src/pages/system/ProfilePage.tsx; pin ProfilePage.access.test.tsx)", "FIXED, regression row, expected PASS (objectui#11778): the flow inspector's Node Type select offers the add-node palette's display names instead of raw snake_case types such as http_request, connector_action and try_catch (pin FlowNodeInspector.nodeTypeOptions-11778.test.tsx); QA run #23163 read the Node Type value as its label", "Regression rows, expected PASS: the audit-log actor and composite field values render as people and labels since objectstack-ai/objectui#11697." @@ -270,7 +282,7 @@ { "clause": "user-facing banners and help text carry no raw spec path, class name or tracker id", "oracle": "dom", - "verify": "no visible string on any variant matches a dotted Zod path, a TypeScript class name such as MetadataClient, or an issue reference", + "verify": "no visible string on any variant matches a dotted Zod path, a TypeScript class name such as MetadataClient, or an issue reference (repo#number, or a bare #NNNN). Score a match by who wrote the text: inside text the PLATFORM builds — a renderer banner, or server-built prose such as Explain's evaluation pipeline — it FAILS; inside text the APP's author wrote (a label, description or flow label carrying a tracker reference) it is an authoring finding, recorded with its surface and scored like negative[0], never a renderer FAIL. An ADR number cited in server-built prose (ADR-NNNN) is not an issue reference: record each one with its surface, and do not score it", "evidence": "every matching string, with its surface" } ], @@ -298,6 +310,12 @@ "date": "2026-10-13", "change": "knownGaps[0]-[2] (knownGaps partly stale, #23163; parked on #23121, carried by #23169). At objectui 1fb1ced36243, still this repo's .objectui-sha: objectui#11866 is fixed, objectui#11778 is fixed for the Node Type select, and objectui#11862 is fixed for the Explain principal line and the nav editor but still reproduces for the Access permission-set list, the Changes panel and the Display-field picker (QA run #23163 commented onto objectui#11862). The run's server-built Explain prose finding is #23157, named so it is not scored against objectui#11862. No clause changed", "ref": "#23169" + }, + { + "revision": 3, + "date": "2026-10-16", + "change": "knownGaps[0] and acceptance[3].verify corrected (checklist-accuracy note 5 of QA run #23953, wave #23878). knownGaps[0] was mostly stale: at objectui daeb77fc84dd the permission-set list, the Changes panel and the Display-field picker lead with the label, and the server-built principal prose names the person (#23157, closed), so those are regression rows; the expected FAIL moves to the two surfaces #23953 extracted as objectui#12693. acceptance[3] did not say whether a tracker reference inside an app's own authored label, or an ADR number inside server-built prose, counts — #23953 left both unscored. The verify text now scores by who wrote the text (a platform-built match FAILS; an author-written one is an authoring finding, the negative[0] rule) and records ADR numbers without scoring them, which is how #23953 read them; whether an ADR citation in user-facing server prose should FAIL is left as an open question on #24036. Not loosened: every match the clause scored before still scores", + "ref": "#24036" } ] }