Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/adr-0085-2548-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/lint': patch
'@objectstack/plugin-audit': patch
---

ADR-0085 #2548 follow-ups surfaced by the real-backend browser pass:

- **lint**: new `field-group-shadowed` warning in `validate-semantic-roles` — a
declared fieldGroup whose every visible member is hoisted into the detail
highlight strip (or is the record title) renders on forms but silently never
on detail pages (detail bodies hide the first 4 highlightFields). Warning
tier, same as the other semantic-role rules.
- **plugin-audit**: feed/audit summaries ("Created … / Deleted … / Updated …")
now name the object by its display label ("Semantic Zoo") instead of its API
name ("showcase_semantic_zoo") — these strings render verbatim in the record
Discussion feed and Setup dashboards. Falls back to the API name when the
object definition isn't resolvable. Existing stored rows are unchanged.
48 changes: 48 additions & 0 deletions .changeset/console-092bd859934f.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@objectstack/console": patch
---

Console (objectui) refreshed to `092bd859934f`. Frontend changes in this range:

- fix(app-shell): bind current_user.positions into the client predicate scope; align role-gating examples (#1583 / ADR-0058) (#2573)
- feat(app-shell): CEL lint + field autocomplete for condition predicates (#1582) (#2567)
- fix(detail): gate related lists on the current user's child-object read permission (#2359) (#2565)
- feat(flow-designer): connector picker lists dispatchable connectors + marks declarative instances (#2563)
- feat(app-shell): Studio CEL editor for list-view conditional formatting (#1584 / #1582) (#2558)
- fix(fields): resolve lookup chip display name via referenced object schema, not the autonumber-prone key heuristic (#2357) (#2551)
- feat(kanban): accept CEL { condition, style } conditional-formatting rules (#1584 follow-up) (#2550)
- fix(plugin-grid): sniff CSV encoding in import wizard (GB18030 fallback) (#2557)
- feat(detail): editable record highlights on the shared inline-edit draft (#2549)
- fix(spec-bridge/form): #2545 stop dropping spec FormViewSchema keys; normalize legacy groups → sections (#2552)
- feat(flow-designer): localize palette headings + cloud-sync recents + guide (#2553)
- fix(studio): refresh builder top-bar name after a package rename (#2554)
- feat(core): B3 cascading-option guardrail, role-gated demo, ADR + browser e2e (#1583) (#2547)
- feat(list): unify conditional formatting + row-action visibility onto the CEL engine (#1584) (#2544)
- feat(detail): record-level inline edit — shared InlineEditContext + one atomic Save (#2542)
- feat(flow-designer): search box + keyboard nav + recents in the add-node palette (#2543)
- feat(kanban): default card fields to object highlightFields (ADR-0085, #2162) (#2541)
- fix(types/plugin-grid): #1763 declare spec-canonical bulkActions on ObjectGridSchema (#2539)
- fix(attachments): download attachments via authenticated signed URL (framework #2970)
- feat(studio): spec-driven package create/edit/view form in a modal (#2535)
- fix(permissions/fields): #2926 ④⑧ — FLS fail-open + lookup display_field (#2537)
- feat(app-shell): CEL authoring safety for RLS policies — lint, field autocomplete, test-run (#2533)
- fix(auth): gate DeviceAuthPage on features.deviceAuthorization (framework#2874 / #2513) (#2536)
- fix(app-shell): close view config panel on discard in edit mode (#2320)
- feat(metadata-admin): create form-family views through the View create UI (#2531)
- fix(app-shell): render action's objectName as an object selector (#2325)
- fix(components): exit inline edit mode for injected cell editors (#2534)
- fix(attachments): authenticated uploads + friendly denial copy in RecordAttachmentsPanel (#2755) (#2532)
- feat(components): page:tabs honors item-level visibleWhen — conditional tabs (framework#2606) (#2516)
- feat(metadata-admin): page variable source is a component picker (#2328) (#2523)
- fix(studio-design): make object canvas overridable via studio-canvas-preview registry (#2337) (#2527)
- fix(metadata-admin): seed flow createDefaults with required `type` (#2525)
- fix(metadata-admin): give hook create form a createSchema so object renders as a ref:object picker (#2521)
- feat(studio): enforce package namespace prefix at authoring time (framework#2694) (#2524)
- fix(app-shell): render View create-form Object field as ref:object picker (#2526)
- feat(components): add `variant === 'primary'` tie-break to action:bar ordering (#2339) (#2519)
- fix(build): stop TS6059 rootDir errors in dts build across 21 packages (#2520)
- fix(app-nav): exclude record-detail pages from the 'page' nav picker (#2333) (#2517)
- fix(plugin-report): stop TS6059 rootDir errors in dts build (#2334) (#2518)
- feat(flow-designer): add 'position' xRef picker kind for approval approvers / escalateTo (#2778) (#2515)

objectui range: `cc2156841787...092bd859934f`
2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
cc2156841787fa6b6e5fc63613782c2a5bb8dbb9
092bd859934f7bbc2654510aef912062c4c4f3a8
14 changes: 13 additions & 1 deletion docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0085: Presentation intent on an object is declared as cross-surface semantic roles, never as per-surface hint blocks (delete `detail`, retire `views.*`, type `stageField`)

**Status**: Proposed (2026-07-02)
**Status**: Accepted (2026-07-16) — proposed 2026-07-02; execution complete through PR4 + real-backend verification, see the as-built addendum
**Deciders**: ObjectStack Protocol Architects
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove trichotomy), [ADR-0078](./0078-no-silently-inert-metadata.md) (completeness gate; "AI is the author, silent no-op manufactures false completion"), [ADR-0032](./0032-unified-expression-layer.md) (validate-by-default, no silent failure), ADR-0079 (`nameField` — the canonical object-level semantic-role precedent)
**Supersedes**: the `detail.*` passthrough-block approach adopted by [objectui#2065](https://github.com/objectstack-ai/objectui/issues/2065) and proposed in [objectui#2148](https://github.com/objectstack-ai/objectui/issues/2148) §方案-6 (that mechanism was the right escape hatch *given* the spec of the day; this ADR replaces the mechanism, not the goals — both issues' goals ship)
Expand Down Expand Up @@ -103,3 +103,15 @@ Warnings (fragile): `Field.group` references an undeclared group key; a declared
- **Add `highlightFields` alongside `compactLayout`** — rejected. Two curated "important fields" lists drift; Salesforce solved this the same way: the compact layout drives the highlights panel. (Resolved instead as a rename: one list, the clearer name.)
- **Keep the `compactLayout` name** — rejected. The name claims a structure the value doesn't have, misses its own biggest consumer (default list columns), and mismatches the renderer vocabulary that already says "highlight" everywhere. A protocol whose keys are named for what they actually do is itself an AI guardrail; the rename costs one alias (ADR-0079 machinery already exists) and a mechanical in-repo sweep.
- **Deprecation window for `views.*`** — rejected as cargo cult: there is no author to deprecate for. Windows are for populations, not for ghosts.

## As-built addendum (2026-07-16)

Execution landed across both repos; differences from the proposal are noted inline.

- **Roles & rename** — `highlightFields` shipped with the ADR-0079 alias machinery; the `compactLayout` alias was then fully retired by framework#2536 (served metadata now carries the canonical key only). `stageField` is spec-typed including the strict-`false` suppression contract (renderer side objectui#2168).
- **§5 shared derivation** — `deriveFieldGroupLayout` lives in `@objectstack/spec` (`packages/spec/src/data/field-group-layout.ts`) and passes group `icon`/`description`/`collapse` through; objectui form/detail synth consume it (the two pre-existing near-copies were deleted).
- **§6 guardrails** — `@objectstack/lint` `validate-semantic-roles`: undeclared `Field.group` reference, declared-but-unreferenced group, dangling `stageField`/`highlightFields` pointers; plus `field-group-shadowed` (added by the #2548 follow-up): a group whose every visible member is hoisted into the detail highlight strip (or is the record title) renders on forms but never on detail pages.
- **PR4 (legacy-path deletion)** — objectui#2546 removed the monolith `DetailView` branch in `RecordDetailView` together with the `detail.renderViaSchema` kill-switch and the `?renderViaSchema=0` debug param; schema-driven is the only path.
- **Cross-surface consumption** — kanban default card fields ride `highlightFields` (objectui#2541), alongside grids/lists/detail strip.
- **Verification** — parse: `@objectstack/spec` suite; served pipeline: `packages/dogfood/test/semantic-roles.dogfood.test.ts`; real-backend browser pass over the four detail shapes (grouped / ungrouped / `stageField: false` / related-heavy): framework#3019, runbook + results in `docs/audits/2026-07-adr-0085-detail-shapes-browser-verify.md`, and a permanent Playwright spec in `examples/app-showcase/e2e/detail-shapes.spec.ts`.
- **Consequence surfaced by the browser pass** — because detail bodies hide strip fields, a fully-highlighted group silently disappears from detail pages. Judged working-as-intended (one curated list, every surface) but author-surprising — hence the `field-group-shadowed` warning and the semantic-zoo fixture keeping one non-highlighted member per group.
34 changes: 23 additions & 11 deletions docs/audits/2026-07-adr-0085-detail-shapes-browser-verify.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,12 @@ curl -s localhost:4600/api/v1/health # -> {"status":"ok"}

# 2. Console — objectui source (post-#2546) pointed at the backend. CORS is
# enabled in dev, so the cross-origin :5180 -> :4600 calls are allowed.
# DEV_PROXY_TARGET matters too: relative-path calls (/api/v1/runtime/config,
# /api/v1/dev/metadata-events) go through the Vite /api proxy, which
# defaults to the dead :3000 without it.
cd ../objectui
VITE_SERVER_URL=http://localhost:4600 pnpm --filter @object-ui/console dev # :5180
VITE_SERVER_URL=http://localhost:4600 DEV_PROXY_TARGET=http://localhost:4600 \
pnpm --filter @object-ui/console dev # :5180

# 3. Auth + fixtures (better-auth Bearer). Sign in, grab the set-auth-token
# header, POST the two semantic-zoo records (not seeded), then drive the
Expand Down Expand Up @@ -82,16 +86,24 @@ highlighted `status/industry/revenue` do not repeat in its Details body. The
untitled bucket) is separately proven over the served pipeline in
`semantic-roles.dogfood.test.ts`.

## Out-of-scope observations (not defects in the detail path)

- `502 /api/v1/runtime/config` and `502 /api/v1/dev/metadata-events` are
**same-origin** requests the Console makes to its own origin (`:5180`); in the
split-origin verify harness the Vite server has nothing there. `initRuntimeConfig`
absorbs the failure by design and the app boots + renders regardless. Not seen
in the normal same-origin (`--ui` / vendored console) deployment.
- `401 /api/v1/approvals/requests?…` (the header approvals badge) is a peripheral
poll unrelated to record rendering; the detail pages loaded their data fine.
Left for separate triage.
## Out-of-scope observations (triaged in the #2548 follow-up)

- `502 /api/v1/runtime/config` / `502 /api/v1/dev/metadata-events` — **harness
misconfiguration, not a product defect**. The console dev server proxies
`/api` to `DEV_PROXY_TARGET` (default `http://localhost:3000`); this pass set
only `VITE_SERVER_URL`, so relative-path calls hit the proxy's dead default.
Correct invocation (now also in the runbook above):
`VITE_SERVER_URL=http://localhost:<port> DEV_PROXY_TARGET=http://localhost:<port> pnpm --filter @object-ui/console dev`.
In production the SPA and runtime share an origin, so relative paths are by
design. `initRuntimeConfig` absorbed the failure and the app rendered anyway.
- `401 /api/v1/approvals/requests?…` (header approvals badge) — **real latent
defect for split-origin deployments**: `approvalsApi` authenticated with
cookies only (`credentials: 'include'`) while every other console API call
sends the Bearer token, so any deployment where the SameSite cookie doesn't
flow (custom-domain console ↔ API split, or this token-injected harness)
loses the approvals surface. Fixed in the objectui #2548 follow-up —
`approvalsApi` now attaches the stored Bearer token like the rest of the
console.

## Conclusion

Expand Down
146 changes: 146 additions & 0 deletions examples/app-showcase/e2e/detail-shapes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { test, expect } from '@playwright/test';

/**
* ADR-0085 detail-shape regression (#2548) — permanent form of the one-off
* real-backend browser verification that closed objectui#2546 (PR4: legacy
* monolith detail renderer + `renderViaSchema` kill-switch removed).
*
* Four shapes, one invariant each, all through the single SchemaRenderer /
* `buildDefaultPageSchema` path the console now has:
* - grouped → fieldGroup sections render; `collapse: 'collapsed'`
* actually collapses (Money hides `budget` until opened)
* - ungrouped → flat details body, no section cards
* - stageField:false → NO `record:path` stepper despite a `status` field
* named to bait the heuristic
* - related-heavy → related lists surface as tabs (prominence rule)
*
* Runs against the console the backend serves at /_console (baseURL from
* playwright.config.ts; storageState carries the admin session). The
* semantic-zoo fixtures are objects but not seeded records — each run
* creates its own rows via the REST API.
*
* NOTE: assertions are scoped to what the *pinned* console build supports
* (.objectui-sha). Group icon/description chrome ships with a later objectui
* bump — extend the grouped case then.
*/

const APP = process.env.SHOWCASE_APP || 'com.example.showcase';
const API = process.env.SMOKE_API_URL || 'http://localhost:3000';
const recordUrl = (object: string, id: string) =>
`/_console/apps/${APP}/${object}/record/${encodeURIComponent(id)}`;

const PATH_STEPPER = '[aria-label="Record path"]';

let zooId = '';
let zooLegacyId = '';
let contosoId = '';

test.beforeAll(async ({ request }) => {
const createRecord = async (object: string, data: Record<string, unknown>) => {
const res = await request.post(`${API}/api/v1/data/${object}`, { data });
expect(res.ok(), `create ${object} failed: ${res.status()} ${await res.text()}`).toBeTruthy();
const body = (await res.json()) as any;
const id = body.id ?? body.record?.id ?? body.data?.id;
expect(id, `no id returned creating ${object}`).toBeTruthy();
return String(id);
};

zooId = await createRecord('showcase_semantic_zoo', {
name: 'E2E Zoo Grouped',
status: 'active',
code: 'ZG-1',
amount: 4200,
budget: 100000,
notes: 'detail-shapes e2e fixture',
});
zooLegacyId = await createRecord('showcase_semantic_zoo_legacy', {
name: 'E2E Zoo Legacy',
status: 'green',
amount: 99,
});

// Contoso is seeded with 2 contacts / 2 invoices / 2 projects — the
// related-heavy shape. Resolve its id instead of assuming one.
const res = await request.get(`${API}/api/v1/data/showcase_account?%24top=50`);
expect(res.ok(), `account list failed: ${res.status()}`).toBeTruthy();
const rows: any[] = ((await res.json()) as any).records ?? [];
const contoso = rows.find((r) => r?.name === 'Contoso');
expect(contoso, 'seeded account "Contoso" not found').toBeTruthy();
contosoId = String(contoso.id);
});

async function openRecord(page: import('@playwright/test').Page, url: string) {
const pageErrors: string[] = [];
page.on('pageerror', (e) => pageErrors.push(e.message));
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.locator('main').first().waitFor({ state: 'visible', timeout: 25_000 });
await page.waitForTimeout(1500);
return pageErrors;
}

test('grouped: fieldGroup sections render and Money starts collapsed', async ({ page }) => {
const errors = await openRecord(page, recordUrl('showcase_semantic_zoo', zooId));

// Stepper from stageField: 'status'.
// record:path renders a desktop + a mobile variant — assert the visible one.
await expect(page.locator(PATH_STEPPER).first()).toBeVisible();

// Declared groups render as titled sections. Their highlighted members
// (status/amount) are hoisted to the strip; the non-highlighted members
// (code/budget) keep the groups visible in the body.
await expect(page.getByText('Basics', { exact: true })).toBeVisible();
await expect(page.getByText('Money', { exact: true })).toBeVisible();
await expect(page.getByText('ZG-1')).toBeVisible();

// collapse: 'collapsed' — Budget stays hidden until the header is opened.
await expect(page.getByText('Budget', { exact: true })).toHaveCount(0);
await page.getByText('Money', { exact: true }).click();
await expect(page.getByText('Budget', { exact: true })).toBeVisible();

expect(errors, 'uncaught page errors on grouped detail').toEqual([]);
});

test('stageField:false: status renders as a plain field, no stepper', async ({ page }) => {
const errors = await openRecord(
page,
recordUrl('showcase_semantic_zoo_legacy', zooLegacyId),
);

// The whole point of `stageField: false`: the status-named select must NOT
// become a chevron path. (objectui#2168 pinned strict-false handling.)
await expect(page.locator(PATH_STEPPER)).toHaveCount(0);
// …but the field itself still renders (as a value, not a lifecycle).
await expect(page.getByText('Green').first()).toBeVisible();

expect(errors, 'uncaught page errors on stageField:false detail').toEqual([]);
});

test('ungrouped + related-heavy: flat details and related-list tabs on Contoso', async ({ page }) => {
const errors = await openRecord(page, recordUrl('showcase_account', contosoId));

// Heuristic stepper (account.status is a select named "status").
// record:path renders a desktop + a mobile variant — assert the visible one.
await expect(page.locator(PATH_STEPPER).first()).toBeVisible();

// Related lists surface as tabs (ADR-0085 prominence rule).
const detailsTab = page.getByRole('tab', { name: /Details/i });
await expect(detailsTab).toBeVisible();
const invoicesTab = page.getByRole('tab', { name: /Invoices/i });
const projectsTab = page.getByRole('tab', { name: /Projects/i });
await expect(invoicesTab).toBeVisible();
await expect(projectsTab).toBeVisible();

// Ungrouped shape: the Details body is flat — no fieldGroup section cards.
// (Contoso declares no fieldGroups; spot-check a body field label.)
await expect(page.getByText('Basics', { exact: true })).toHaveCount(0);

// Related lists self-fetch lazily when their tab is shown.
await invoicesTab.click();
const panel = page.getByRole('tabpanel');
await expect(panel).toBeVisible();
await page.waitForTimeout(1500);
const panelText = (await panel.innerText().catch(() => '')) || '';
expect(panelText.trim().length, 'invoices tab rendered no content').toBeGreaterThan(0);

expect(errors, 'uncaught page errors on related-heavy detail').toEqual([]);
});
11 changes: 10 additions & 1 deletion examples/app-showcase/src/data/objects/account.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,16 @@ export const Account = ObjectSchema.create({
{ label: 'Retail', value: 'retail' },
],
}),
annual_revenue: Field.currency({ label: 'Annual Revenue', scale: 2, min: 0 }),
// Explicit ISO code (spec channel: `currencyConfig.defaultCurrency`): a
// currency field without a resolvable code renders as a bare grouped
// number by design (no guessed symbol) — with it, the detail highlight
// strip and grids show "$25,000,000" instead of "25,000,000".
annual_revenue: Field.currency({
label: 'Annual Revenue',
scale: 2,
min: 0,
currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'USD' },
}),
website: Field.url({ label: 'Website' }),
hq: Field.location({ label: 'Headquarters' }),
status: Field.select({
Expand Down
Loading