Skip to content

fix(spec)!: an action's newTabUrl is a backend path starting with /; dashboard read-refusal and citation prose - #27252

Merged
os-zhuang merged 4 commits into
mainfrom
claude/project-thread-ev6wfx
Oct 28, 2026
Merged

os-zhuang merged 4 commits into
mainfrom
claude/project-thread-ev6wfx

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread
Fixes #26924
Fixes #27068
Fixes #26973

Ruling-ref: none (seat choice A on #26924, per the card's options and triage 6454574648)
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: updated here — skills/objectstack-ui/rules/actions.md (the one skills/** sentence that taught newTabUrl, "For external deep-links … a direct URL template", now teaches a backend path and points an external page at openIn); no eval under skills/objectstack-ui/evals/ exercises newTabUrl (0 hits), so an eval case is follow-up owed if the seat wants one. skills/** carries no north-star citation (0 hits). Tier H: this PR edits skills/**.

维护者速读

What changes

  • spec: ActionSchema.newTabUrl is a "path template" in its JSDoc but a "URL template" in its describe; an absolute value opens backend origin + the URL #26924, @objectstack/spec (minor, BREAKING). ActionSchema.newTabUrl is z.string().regex(/^\//, …): a value that does not start with / is refused at newTabUrl with `newTabUrl` is a path on this backend and must start with `/` (e.g. `/sso-open/{recordId}`): the console prefixes the backend origin to it, so an absolute or relative URL opens a broken tab. To open an external page, use a `type: 'url'` action with `target: 'https://…'` (`${ctx.recordId}` where the record id goes) and `openIn: 'new-tab'`. A native string check, so it projects to the published JSON Schema as a pattern and adds no dropped-refinement site (the spec build is unchanged on that ledger). The external-page remedy is measured, not assumed: type: 'url' is the action type whose target takes an http(s) URL, and openIn: 'new-tab' is its new-tab switch (action.zod.ts target / openIn); the test parses that exact shape. The JSDoc, .describe() and the action form's help text (action.form.ts) now all say "a path on this backend, starting with /", and the four *.metadata-forms.generated.ts bundles carry the new help text (en from source; zh-CN / ja-JP / es-ES hand-translated, keys unchanged). No renderer change: consoleServerAction.ts already prefixes the backend origin.
  • ADR-0087: not-required (no-migration-prescription). No backend path can be derived from an external URL, so nothing converts a stored value. What load does to one, measured on this head (new pins in protocol.stored-conversions.test.ts): loadMetaFromDb → { loaded: 1, errors: 0, invalid: 1 }, the row registers with its stored newTabUrl, and boot prints [Protocol] [metadata_spec_invalid] stored action/export_invoice fails the current spec schema even after conversion (newTabUrl: …the prescription…). Registered anyway so it stays serveable and fixable …; getMetaItem serves the bytes unchanged with _diagnostics.valid: false locating newTabUrl; a CONTROL row with a path loads invalid: 0. Save, measured with a one-off probe through saveMetaItem (deleted after use): the absolute value answers 422 INVALID_METADATA action/export_invoice failed spec validation: newTabUrl: …the prescription…; the same body with /export/{recordId} saves. The same sentence is in the JSDoc, the changeset and the disposition marker.
  • Liveness. liveness/action.json newTabUrl: verdict unchanged (live), now with in-repo evidence (consoleServerAction.ts#createConsoleServerActionHandler) and a note stating the path rule.
  • Survey (metadata in the repository, base 835b2d1c). Every newTabUrl value written anywhere in the tree (CHANGELOGs excluded): 20 literal sites (packages/spec tests 8, ui/ tests 11, skills/** 1), 0 that do not start with /; examples/** 0, ui/examples/** 0, no JSON fixture carries the key. The cloud SSO producers live in objectstack-ai/cloud, which this session cannot read; the card names /sso-open as their endpoint, a path. A deployed database's sys_metadata rows cannot be surveyed from here; an absolute value there behaves as measured above.
  • spec(ui): the dashboard widget describe does not say that a dataset tile the server refuses as a read refusal is left out for that viewer; requiresObject keeps its install-only meaning #27068, @objectstack/spec (prose only, no key). Measured on main first: DashboardRenderer.tsx (refusedTiles, and renderWidget's if (!designMode && refusedTileKey && refusedTiles.has(refusedTileKey)) return null) leaves out a dataset-bound tile once DatasetWidget's onForbidden fires, which it does only when classifyLoadError(e) === 'forbidden' (PR fix(plugin-dashboard): leave a tile out when the server refuses its data to the viewer #27065, merged as 4aeb0543). The DashboardWidgetSchema docblock and requiresObject's JSDoc and .describe() now say: outside design mode, a dataset-bound widget whose query the server refuses as a read refusal (403 PERMISSION_DENIED) is left out for that viewer, with no key; in design mode it stays and shows its no-access state; requiresObject / requiresService keep their install-only meaning. liveness/dashboard.json requiresObject note gains the same sentence. No accept-set change.
  • docs(spec, formula): five shipped citations point at content/docs/concepts/north-star.mdx "§8" / "§6.3", sections that page never had, and the path is renamed by #26073 #26973, @objectstack/spec + @objectstack/formula (prose only). content/docs/concepts/architecture-overview.mdx (the renamed page) has the headings Principles, Runtime Shape, Environment Artifact, Request Resolution, Non-Goals. Each citation:
    • environment-artifact.zod.ts "§6.3 … runtime-inputs boundary" → architecture-overview.mdx § "Environment Artifact", which states it ("It is not enough to deploy by itself; the host still supplies deployment config").
    • expression.zod.ts, packages/formula/src/index.ts, packages/formula/README.md "§8 No private expression DSL" → no heading on the renamed page carries it; ADR-0032 does (its title: "… one CEL language …"), so the three cite docs/adr/0032-unified-expression-layer.md. This is the card's "the section that actually states the claim" rather than a bare drop; say if the seat wants the drop instead.
    • hook-body.zod.ts "Metadata-only runtime" → no such heading anywhere; the pointer is dropped (its docblock already states the claim in full).
    • git grep -n 'north-star.mdx' -- packages/ → 0 (positive control: git grep -n architecture -- packages/spec/src hits). The references shared/expression.mdx and system/environment-artifact.mdx are regenerated.
  • skills/objectstack-ui/rules/actions.md (Tier H). The sentence "For external deep-links with no server round-trip, add newTabUrl — a direct URL template" would steer an author into the refused value; it now says a backend path starting with /, and points an external URL at openIn above. Readings: the file 204 → 204 lines, 2259 → 2258 tokens (ceiling 2259); the bundle (every SKILL.md) 4615 → 4615 lines; the whole shipped tree 215844 → 215843 tokens. Paid by deleting words in the same paragraph, no re-wrap credit.
  • Changesets. @objectstack/spec minor (the narrowing, with the ADR-0087 marker; the dashboard and citation prose ride in it), @objectstack/formula patch (README and docblock citation), @objectstack/platform-objects patch (the four form-translation bundles).

Tests

  • packages/spec/src/ui/action-newtaburl-path.test.ts (new, 7): https:// and http:// values and a relative sso-open/{recordId} are refused at newTabUrl (invalid_format, one issue), the first sentence and the type: 'url' / openIn: 'new-tab' prescription asserted; refused through the registered action metadata schema too; /api/v1/invoice/{recordId}/pdf parses byte-identically; a query-string path passes; the remedy shape (type: 'url', https:// target, openIn: 'new-tab') parses.
  • packages/metadata-protocol/src/protocol.stored-conversions.test.ts (3 new): the stored absolute row is served unchanged with _diagnostics naming newTabUrl; boot counts it invalid: 1 and still registers it with the stored value; CONTROL: the same row with a path loads valid.
  • Ablation (one-off, not kept): scripts/ablation-replace.mjs turned the rule into regex(/^/, …) → the 4 refusal pins red, the 3 acceptance pins green; restored, blob == HEAD and git diff HEAD empty.

Acceptance notes

  • //host and /\host start with / and pass. The console concatenates them after the backend origin, so they open a path on the backend, not another origin; refusing them was not asked and would be a wider narrowing.
  • The console substitutes only the first {recordId} (String.replace with a string pattern). No value in the tree repeats the placeholder; not changed here (no renderer change by order).
  • The action form's newTabUrl label stays "New-tab URL" (zh-CN "新标签页 URL 模板"); only the help text moved.
  • content/docs/protocol/objectui/actions.mdx calls newTabUrl "a zero-roundtrip new-tab target template"; still true under the rule, so left alone.

Verification

All readings on a shared container, base main d745bb5e5 after merging it (no overlap with this diff); the lock's VERDICT lines are quoted.

Tests and generated artifacts (head 7131b81)
  • pnpm --filter @objectstack/spec build → VERDICT command-exit 0 (dropped-refinements ledger unchanged; the published ui/Action.json now carries newTabUrl: { type: string, pattern: '^\/' }).
  • pnpm --filter @objectstack/spec check:generated --fix → only check:docs was stale (6 reference pages regenerated, committed); VERDICT command-exit 0.
  • pnpm --filter @objectstack/spec test → Test Files 744 passed (744), Tests 22270 passed | 1 todo; typecheck → exit 0.
  • pnpm --filter '@objectstack/metadata-protocol^...' build then pnpm --filter @objectstack/metadata-protocol test → Test Files 277 passed | 4 skipped (281), Tests 21753 passed | 26 skipped.
  • pnpm --filter @objectstack/formula test → Test Files 57 passed (57), Tests 1522 passed; typecheck → exit 0.
  • pnpm --filter @objectstack/platform-objects test → Test Files 96 passed (96), Tests 1361 passed.
  • One locked run held all of the above: VERDICT command-exit 0 · held the lock 648s.
  • Single files: action-newtaburl-path.test.ts → 7 passed; protocol.stored-conversions.test.ts → 19 passed (the boot line quoted under What changes is this run's stderr).
  • node scripts/check-skills-token-ratchet.mjs → skills/objectstack-ui/rules/actions.md is 2258 tokens (ceiling 2259; headroom 1).
Full dogfood (head 7131b81)
  • pnpm --filter '@objectstack/dogfood^...' build then pnpm --filter @objectstack/dogfood test (no shard, no path), one locked run → VERDICT command-exit 0 · held the lock 1961s. Test Files 348 passed | 3 skipped (351), Tests 2804 passed | 20 skipped. No failure.
Derived gates (head 7131b81)
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack → 115 families (26 paths, 288 changed lines vs merge base d745bb5e5).
  • Run serially: 113 exit 0 on the first pass; spec check:skill-examples and check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET: no client-react / plugin dists). After building those dists (VERDICT command-exit 0 · 280s): check:skill-examples exit 0 (270 prose examples type-check across 3 surfaces), check:dual-build-cjs-loads exit 0 (112 entry points across 69 packages load).
  • dispatch-gates --ran → 115 derived famil(ies) accounted for — 115 run, 0 NOT-MEASURED, exit 0.
  • GITHUB_EVENT_NAME=pull_request node scripts/check-changeset-no-major.mjs --base origin/main --event FILE (this body) → exit 0.
  • CI check-runs: in progress at open; the seat reads them.

Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review October 28, 2026 12:59
@os-zhuang
os-zhuang enabled auto-merge October 28, 2026 12:59
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/formula, @objectstack/platform-objects, @objectstack/spec, touching 4 documentable anchor(s). ⚠️ 7 changed file(s) yielded no anchor (packages/formula/README.md, packages/formula/src/index.ts, packages/spec/liveness/action.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/data-modeling/analytics.mdx (via DashboardWidgetSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/actions.mdx (via newTabUrl (literal, a string literal in sections))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via DashboardWidgetSchema (symbol, a top-level const))
  • content/docs/releases/v16.mdx (via DashboardWidgetSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-3.mdx (via newTabUrl (literal, a string literal in sections))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 7 changed file(s) yielded no anchor (packages/formula/README.md, packages/formula/src/index.ts, packages/spec/liveness/action.json, …) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 56 of 208 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 149 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 9ff8154cf694c616de5d467b35af21e037de1509 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 15167534024bd369a4807e9fe6b4d8408b198b59 — the merge of head 7131b8152cad219d3cf15405d4a95d613864288a into base 9ff8154cf694c616de5d467b35af21e037de1509, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 15167534024bd369a4807e9fe6b4d8408b198b59 && git checkout 15167534024bd369a4807e9fe6b4d8408b198b59
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9ff8154cf694c616de5d467b35af21e037de1509 7131b8152cad219d3cf15405d4a95d613864288a && git checkout -B drift-repro 9ff8154cf694c616de5d467b35af21e037de1509 && git merge --no-ff 7131b8152cad219d3cf15405d4a95d613864288a

node scripts/docs-audit/affected-docs.mjs --json 9ff8154cf694c616de5d467b35af21e037de1509

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 9ff8154cf694c616de5d467b35af21e037de1509 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 7131b8152cad219d3cf15405d4a95d613864288a · 2026-10-28T13:07Z

Triage seat, under the gap rule (#27062) condition (b): no record and no claim on this head after one round. The review runs at CONTRACT_REVIEW_TIER. If the lane claims or posts a record on this head first, triage withdraws.


Generated by Claude Code

@os-zhuang
os-zhuang added this pull request to the merge queue Oct 28, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7131b8152cad219d3cf15405d4a95d613864288a
Local-runs: none
Scope: packages/spec/src/data/hook-body.zod.ts, packages/spec/src/shared/expression.zod.ts, packages/spec/src/system/environment-artifact.zod.ts, packages/spec/src/ui/action.form.ts, packages/spec/src/ui/action.zod.ts, packages/spec/src/ui/dashboard.zod.ts, skills/objectstack-ui/rules/actions.md + packages/spec/src/ui/action-newtaburl-path.test.ts, packages/metadata-protocol/src/protocol.stored-conversions.test.ts, packages/formula/src/index.ts, packages/formula/README.md, packages/spec/liveness/action.json, packages/spec/liveness/dashboard.json, .changeset/26924-new-tab-url-backend-path.md, and read in the cited code: packages/spec/src/kernel/metadata-type-schemas.ts, packages/spec/src/data/object.zod.ts, packages/spec/src/api/metadata.zod.ts, packages/spec/src/kernel/metadata-plugin.zod.ts, packages/metadata-protocol/src/protocol.ts (boot [metadata_spec_invalid] path, 422 INVALID_METADATA save gate), ui/packages/app-shell/src/utils/consoleServerAction.ts, ui/packages/app-shell/src/utils/newTabUrlFastPath.ts, ui/packages/plugin-dashboard/src/DashboardRenderer.tsx, ui/packages/plugin-dashboard/src/DatasetWidget.tsx, ui/packages/react/src/utils/error-message.ts, docs/adr/0032-unified-expression-layer.md, docs/adr/0087-metadata-protocol-upgrade-contract.md, content/docs/concepts/architecture-overview.mdx, scripts/check-adr-0087-registration.mjs, scripts/check-skills-token-ratchet.mjs, packages/spec/src/migrations/entries/semantic/18.ui-action-undoable-unfulfillable-refused.ts (precedent). Outside this scope — the three changesets' prose, the six regenerated content/docs/references/** pages and the four *.metadata-forms.generated.ts bundles — is the lane seat's ACCEPT at TIER_DEFAULT.

Inputs: cards #26924, #27068, #26973 (bodies, triage, claims, the os-dev-report 6469626203), the PR body and file list, the net diff d745bb5e5…7131b8152, and the check-runs on the head. Gates on the head, read to completion (51 check-runs, 0 pending at 13:20Z): every required context is success — Lint & Repo Gates, TypeScript Type Check, Test Core (8/8 shards), Build Core, Governed Surface Queue Guard, Build & E2E UI, Type Check UI, Test UI, Live E2E UI — and Lint UI is skipped (its paths filter; no ui/** on the file list). Check Changeset, Spec property liveness, Dogfood Regression Gate and the PR-body guards are success; no check-run is failure. Those conclusions are the gate verdicts here; the FAIL below is a judgment the gates do not read (② explains why the ADR-0087 gate passed this body).

① Derived judgments

  • Accept-set narrowing, ActionSchema.newTabUrl — right. actionObject() (action.zod.ts:1730) is the only declaration of the key in packages/spec/src (grep over the head tree: no second newTabUrl field anywhere). Every door that admits an action reaches it: defineAction is ActionSchema.parse; the registered action metadata type (metadata-type-schemas.ts:118), so /meta save and boot hydration; ObjectSchema.actions[] (object.zod.ts:3370); type-level actions (api/metadata.zod.ts:304, kernel/metadata-plugin.zod.ts:408). InlineActionSchema picks opensInNewTab but not newTabUrl from the strict template, and the page/component action rows refuse newTabUrl as unrecognized_keys (pinned in component-action-row-declarative-update.test.ts:235) — the key cannot enter unparsed anywhere. A native z.string().regex(/^\//) projects to a JSON Schema pattern; the issue code is invalid_format, which the new test pins together with the prescription text.
  • The consumer still agrees with the prose — right. consoleServerAction.ts:209 builds ${opts.baseUrl() || window.location.origin}${newTabUrl.replace('{recordId}', …)} behind takesNewTabUrlFastPath (opensInNewTab plus a non-empty newTabUrl). No other reader in ui/** navigates the key (ActionRunner.ts:351 is the type, serverActionHandler.ts:199 a comment, ActionDefaultInspector.tsx:369 a key list). The JSDoc, .describe(), form help, the four form-translation bundles, liveness/action.json and the skills sentence now all say "a path on this backend, starting with /", and the renderer does exactly that. The acceptance note on //host is correct: concatenated after an origin, it stays a path on the backend; the url-action target refuses //host for the opposite reason (it is navigated verbatim), so the two rules are consistent, not inconsistent.
  • The external-page prescription is measured — right. type: 'url' takes an http(s) target with ${ctx.recordId} interpolation (action.zod.ts:1105-1125) and openIn: 'new-tab' (:1143); the new test parses that exact shape.
  • In-tree producers — right. At the head tree: 25 literal newTabUrl values (CHANGELOGs excluded); the only three not starting with / are the new refusal fixtures (action-newtaburl-path.test.ts, protocol.stored-conversions.test.ts). The one shipped author-facing example (skills/objectstack-ui/rules/actions.md:183) is /api/v1/invoice/{recordId}/pdf. No example app, fixture or platform object carries the key.
  • Stored-row behaviour as stated is TRUE in code — right. protocol.ts:28684-28708: a non-object row that fails the current schema after the conversion chain is counted invalid, warned [Protocol] [metadata_spec_invalid] stored action/… fails the current spec schema even after conversion (newTabUrl: …). Registered anyway so it stays serveable and fixable, and registered with its stored bytes; getMetaItem serves it with _diagnostics; the save gate answers 422 INVALID_METADATA through the same action schema. The three new pins in protocol.stored-conversions.test.ts hold the load and read halves against this head. The row keeps opening the broken tab until rewritten — the changeset says so.
  • spec(ui): the dashboard widget describe does not say that a dataset tile the server refuses as a read refusal is left out for that viewer; requiresObject keeps its install-only meaning #27068 dashboard prose — right. DashboardRenderer.tsx:1104-1105 returns null for a dataset-bound tile in refusedTiles when !designMode; the set is fed only by DatasetWidget's onForbidden (:1175), which fires only when classifyLoadError(e) === 'forbidden' (DatasetWidget.tsx:990-992; error-message.ts:49: status 403 or code PERMISSION_DENIED / FORBIDDEN). In design mode the tile stays and draws the no-access state (:383-392). requiresObject is pruned client-side on registry presence (DashboardView.tsx, unchanged liveness evidence) — install gate, not viewer gate. The sentence added to the docblock, requiresObject's JSDoc and .describe(), and liveness/dashboard.json match that code. No key, no accept-set change.
  • docs(spec, formula): five shipped citations point at content/docs/concepts/north-star.mdx "§8" / "§6.3", sections that page never had, and the path is renamed by #26073 #26973 citations — right, with the dev's deviation answered below. environment-artifact.zod.ts now cites architecture-overview.mdx § "Environment Artifact", which states the runtime-inputs boundary verbatim ("It is not enough to deploy by itself; the host still supplies deployment config"). hook-body.zod.ts drops a pointer to a heading that exists nowhere; its docblock states the metadata-only claim itself. The three "No private expression DSL" citations now read docs/adr/0032-unified-expression-layer.md — "one CEL language": that phrase is ADR-0032's title, and its TL;DR item 5 and D2 decide one canonical expression language and IR behind one validator — which is the claim the @objectstack/formula docblock/README ("Canonical expression engine … CEL is the default dialect") and the expression.zod.ts dialect table ("those three are the whole list") make. The phrase "No private expression DSL" exists nowhere in the repo (NORTH-STAR.md, architecture-overview.mdx, docs/adr, ROADMAP.md: 0 hits), so a re-worded quote that the cited record actually carries is the honest form; the citation supports its sentence. git grep north-star.mdx -- packages/ is 0 on the head tree; the positive control hits.
  • Skills rule text (Tier H) — right. Every taught sentence in actions.md:164-173 matches the schema: backend path starting with /, {recordId} placeholder, external URL refused with openIn named — and the openIn: 'new-tab' section it points "above" is the section immediately above it. Ratchet: 9035 → 9032 bytes, ceil(9032/4) = 2258 tokens against the 2259 ceiling (check-skills-token-ratchet.mjs:814); 204 lines unchanged; paid in the same paragraph. The record here is the contract-tier review; the landing stays Tier H — a maintainer's approval — because skills/** is on the file list.
  • Liveness rows — right. action.json newTabUrl keeps live and gains the real evidence symbol; dashboard.json requiresObject keeps its evidence and gains the read-refusal sentence.

② Semver level

  • Clause-②: no (narrowing) and Metadata-protocol: narrowed on the PR body and on .changeset/26924-new-tab-url-backend-path.md — the arm matches the diff: an accepted value set shrinks, nothing widens. @objectstack/spec minor with the **BREAKING** banner is what a narrowed kind takes (Post-Task Checklist 3; ADR-0087 2026-09-13 level half: pre-GA a metadata-facing break ships minor carrying the banner and its disposition). @objectstack/formula patch (prose only, Clause-②: no, Metadata-protocol: none) and @objectstack/platform-objects patch (help-text strings in the generated bundles, none) — right. No skip-changeset; every published package the diff moves carries a changeset.
  • ADR-0087 disposition — WRONG; the one FAIL item. The changeset declares not-required (no-migration-prescription). ADR-0087's single rule for that category is "refused by a body that carries a migration prescription" (addendum 2026-08-13; gate header check-adr-0087-registration.mjs:147-153: "A changeset that ships instructions for rewriting a consumer's code cannot also claim no consumer has to rewrite anything"). This body does ship them — "The remedy. For an endpoint on this backend, write its path … For an external page, drop opensInNewTab and newTabUrl and declare a type: 'url' action instead: target: …, openIn: 'new-tab'" — and the JSDoc says the stored row's "next save is refused until the author rewrites it". That is a consumer rewrite, stated in the same document that claims none exists. The gate passed because its detector reads FROM/TO labels, arrows, vertical FROM/TO pairs and rewrite tables (FROM_TO_LABEL_RE, REWRITE_RE, VERTICAL_*_RE) and this body spells its prescription as "Before. / Now. / The remedy." — a detector miss, which ADR-0087 itself names as the ADR-0087's changeset disposition has no category for published runtime TS interfaces with no metadata surface — the #8277 exemption argument is correct, unverifiable, and will be re-litigated #8299 anti-pattern and refuses as precedent (addendum 2026-08-30, "a COUNTER-EXAMPLE, not a precedent"). The decision text points the other way in every case: D3 — "A migration that cannot be expressed declaratively gets a structured TODO entry (surface, reason, acceptance criteria) rather than silence"; the launch-window ratification — "one D3 semantic entry per retirement family in every case". The repo's own pattern for exactly this class is a semantic entry (505 entries; e.g. 18.ui-action-undoable-unfulfillable-refused.ts, whose replacement says "NOT mechanically convertible … the author chooses", and visibility-predicate-has-argument-refused, a value refused at a door, declared registered). The dev's reasoning — "no backend path can be derived from an external URL, so nothing converts" — is true of the first remedy and silent on the second, which is derivable in principle (an http(s) value is an external page; the url-action shape is written in the body); whether to ship that as a conversion or as a structured TODO is the entry's replacement to argue, not a reason to skip the chain step. Without it a tenant with such a row has no objectstack migrate meta step and no upgrade-guide line, and learns at the next save.
    Change: add packages/spec/src/migrations/entries/semantic/18.ui-action-new-tab-url-path-refused.ts (id, surface: an action document whose newTabUrl does not start with /; replacement: the backend path, or the type: 'url' + target + openIn: 'new-tab' action for an external page, stating whether the url-action rewrite is applied mechanically or left to the author as a structured TODO; reason; acceptanceCriteria naming the stored-row behaviour already pinned), and change the marker to adr-0087: registered ui-action-new-tab-url-path-refused in the same HTML comment. gen:migration-registry writes gitignored output (nothing else to commit); check:spec-changes / check:upgrade-guide then project it. Keep the stored-row prose and pins as they are.

③ Boundary flags

  • Dev open_questions — ADR-0032 citations: keep. Answered above: the cited record carries the quoted phrase in its title and decides the claim the sentences make; a bare drop would leave three shipped sentences with no source. The order's "renamed page or drop" was written before the dev measured that no heading on the renamed page carries the claim; citing the deciding ADR is the card's own "section that actually states the claim". No change.
  • //host, /\host pass the regex — accepted: same-origin concatenation, not a widening (judgment above). No change.
  • Only the first {recordId} is substituted (String.replace) — out of scope by order, no in-tree value repeats the placeholder; noted, not blocking.
  • Form label stays "New-tab URL" (zh-CN "新标签页 URL 模板") — the help text now governs the meaning; acceptable. Should-fix, non-blocking: the zh-CN label still says 模板 (template) and URL; a follow-up may rename the four labels to "path".
  • content/docs/protocol/objectui/actions.mdx:110 "zero-roundtrip new-tab target template" — still true; context-tokens.zod.ts:109 "a URL placeholder" likewise. No change.
  • No skills/objectstack-ui/evals/ case exercises newTabUrl — follow-up owed to the lane seat, not this PR.
  • Deployed sys_metadata rows cannot be surveyed from here — the behaviour of such a row is pinned on this head; the FAIL item gives those rows their chain step.
  • Tier H — skills/objectstack-ui/rules/actions.md is on the file list; this record is the contract-tier review of the schema and rule faces, and the landing needs the maintainer's approval as the PR body says. Nothing here lifts that.
  • Stacked / shared-branch — the head is the thread's own branch claude/project-thread-ev6wfx, same-repo, d745bb5e5 merge base, 288 changed lines; no stacking.

Implemented-by: claude/project-thread-ev6wfx
Reviewed-by: session_01WGufzgbJhFoXYsLSe5WSPP

VERDICT: FAIL

One item: the ADR-0087 disposition on .changeset/26924-new-tab-url-backend-path.md — register the D3 semantic entry and switch the marker to registered. Everything else on the schema, rule and prose faces reads right against the cited code and may stay as it is; re-render on the new head.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7131b8152cad219d3cf15405d4a95d613864288a
Local-runs: none
Scope: packages/spec/src/data/hook-body.zod.ts, packages/spec/src/shared/expression.zod.ts, packages/spec/src/system/environment-artifact.zod.ts, packages/spec/src/ui/action.form.ts, packages/spec/src/ui/action.zod.ts, packages/spec/src/ui/dashboard.zod.ts, skills/objectstack-ui/rules/actions.md + ui/packages/app-shell/src/utils/consoleServerAction.ts (the fast path the rule is derived from, lines 182-211), ui/packages/app-shell/src/utils/newTabUrlFastPath.ts (takesNewTabUrlFastPath), ui/packages/plugin-dashboard/src/DashboardRenderer.tsx (lines 385-395, 1104-1105, 1175) and ui/packages/plugin-dashboard/src/DatasetWidget.tsx (lines 985-995) (the behaviour the dashboard prose states), docs/adr/0032-unified-expression-layer.md and content/docs/concepts/architecture-overview.mdx (the citation targets), docs/adr/0087-metadata-protocol-upgrade-contract.md and scripts/check-adr-0087-registration.mjs (the disposition arm), scripts/check-skills-token-ratchet.mjs:814 (the skill file's ceiling), .claude/skills/pm-dispatch/SKILL.md:313 and references/review-checklist.md:18 (the Skills-docs: rule), packages/metadata-protocol/src/protocol.ts:28702-28705 (the boot line the JSDoc quotes), packages/spec/src/ui/action-newtaburl-path.test.ts and packages/metadata-protocol/src/protocol.stored-conversions.test.ts (the pins; read, not run). The rest of the diff (changesets, six regenerated content/docs/references/** pages, the four *.metadata-forms.generated.ts bundles, packages/formula/**, two liveness ledgers) is the lane seat's ACCEPT at TIER_DEFAULT.

Inputs read: card #26924 body and all 4 comments (triage 6454574648, claim 6468072560, amendment 6468134412, os-dev-report 6469626203); PR #27252 body, file list (26), net diff vs merge-base d745bb5e5 (+254/-34); check-runs on the head re-fetched three times (final: 50 runs, 0 failures, 1 pending); the cards the PR also closes, #27068 and #26973, read by gh api GET as the orders the dashboard.zod.ts and citation hunks implement; ruling #26369 (letter B, 6431004737) for the Skills-docs: rule.

① Derived judgments

  • Target A implemented, nothing of B. ActionSchema.newTabUrl (action.zod.ts:1730) is z.string().regex(/^\//, { message }).optional(). The diff touches no ui/** file: consoleServerAction.ts:209 still builds ${baseUrl() || window.location.origin}${newTabUrl.replace('{recordId}', …)}, so the renderer keeps treating the value as a path, exactly as triage 6454574648 ruled A. Nothing in the diff declares an absolute URL legal anywhere. Right.
  • Accept-set narrowing, one key, every door that carries it. The field lives in actionObject(), so ActionSchema, the registered action metadata type (getMetadataTypeSchema('action'), pinned by the test) and an object's actions[] all narrow. InlineActionSchema (action.zod.ts:2380-2396) picks opensInNewTab but never newTabUrl, so inline actions are untouched — the changeset's "every action door" list (defineAction, action type, actions[]) is exactly right and not wider. Right.
  • Published JSON Schema. A native z.string().regex projects as pattern: '^\/' on ui/Action.newTabUrl; it is a check, not a .refine, so packages/spec/dropped-refinements.baseline.json is rightly untouched. Build Core and Spec property liveness are green on the head. Right.
  • TS surface unchanged. newTabUrl?: string stays string; Type Check UI, Type Check · workspace, Type Check · consumer gates green. No ui/ fix owed. Right.
  • Co-constraint untouched. The ActionSchema documents "newTabUrl is only valid together with opensInNewTab" but no refine enforces the pair (observation) #11842 pair refine (action.zod.ts:2216-2249) still fires on a lone newTabUrl; its message already points a STATIC url action at openIn: "new-tab", which agrees with the new refusal's remedy. The two refusals compose: an absolute URL beside opensInNewTab: true gets one invalid_format issue at newTabUrl (the test's toHaveLength(1) holds because the pair refine passes). Right.
  • The remedy the refusal names is real. ActionType has 'url' (:585); target on type:'url' takes http(s):// and ${ctx.X} interpolation (:1125); top-level openIn is z.enum(['self','new-tab']) (:1143). The refusal text spells the kebab 'new-tab', not onSuccess.openIn's 'newTab'. Right.
  • //host and /\host pass. They start with /; concatenated after the backend origin they stay on that origin (https://backend//host/…), so they are not an escape to another origin. Refusing them was not asked. Right, and correctly left alone.
  • Stored metadata. No conversion: ADR-0087 arm not-required (no-migration-prescription). A stored absolute value loads unchanged, counts invalid, prints [Protocol] [metadata_spec_invalid] stored action/NAME fails the current spec schema even after conversion … Registered anyway … (protocol.ts:28702-28705 carries exactly that text), is served with _diagnostics, and its next save is refused 422. Pinned by three new tests in protocol.stored-conversions.test.ts. Right — see ② for the arm.
  • Survey. git grep newTabUrl on the head and on origin/main (4d3a077f), CHANGELOGs excluded: every literal value in the tree starts with / (/sso-open/{recordId}, /sso-open?recordId={recordId}, /export/{recordId}, /api/v1/invoice/{recordId}/pdf, /api/v1/cloud/environments/{recordId}/sso-open, /open/{recordId}, /x/{recordId}); the only non-/ values are the new refusal pins themselves. examples/**, ui/examples/**, JSON fixtures: 0 carriers. The ui/ type doc (ui/packages/types/src/ui-action.ts) already says "a path template". The cloud SSO producers live outside this repo; the card names /sso-open, a path. Right.
  • Dashboard prose (spec(ui): the dashboard widget describe does not say that a dataset tile the server refuses as a read refusal is left out for that viewer; requiresObject keeps its install-only meaning #27068), no key. DashboardRenderer.tsx:1105 if (!designMode && refusedTileKey && refusedTiles.has(refusedTileKey)) return null;, fed by DatasetWidget.tsx:990-992 if (classifyLoadError(e) === 'forbidden') { …; onForbidden?.(); }; in design mode the tile stays and draws the no-access state (:389-391). fix(plugin-dashboard): leave a tile out when the server refuses its data to the viewer #27065 (4aeb0543) is an ancestor of the head, so the sentence is true in this tree, not only on main. requiresObject keeps its install-only meaning; the liveness note agrees. Right.
  • Citations (docs(spec, formula): five shipped citations point at content/docs/concepts/north-star.mdx "§8" / "§6.3", sections that page never had, and the path is renamed by #26073 #26973). content/docs/concepts/north-star.mdx no longer exists on the head; git grep north-star -- packages ui/packages skills content/docs/references → 0. architecture-overview.mdx has ## Environment Artifact (:64) stating "the host still supplies deployment config" (:88) — the §6.3 claim. ADR-0032's title carries "one CEL language"; neither architecture-overview.mdx nor docs/NORTH-STAR.md states "no private expression DSL" (grep: 0), so ADR-0032 is the record that states the claim. No heading anywhere reads "Metadata-only runtime"; the hook-body.zod.ts docblock already states the claim in full, so the drop loses nothing. Right.

② Semver level

  • @objectstack/spec minor with **BREAKING** banner, Clause-②: no (narrowing), Metadata-protocol: narrowed, one HTML-comment marker adr-0087: not-required (no-migration-prescription) …. AGENTS.md step 3: a kind other than none takes at least minor; (narrowing) is BREAKING; a breaking changeset carries exactly one ADR-0087 marker. Matches. Check Changeset (the job that runs check-adr-0087-registration.mjs --base MERGE_BASE and check-changeset-no-major.mjs) is green on the head.
  • On the arm itself, judged against the ADR not the gate: the body's "The remedy." paragraph is author guidance (write a path, or re-author as a type:'url' action), not a FROM→TO mechanical rewrite — no backend path is derivable from an external URL, which is the feat(runtime)!: 退役 ctx.user 的 roles 别名,positions 成为唯一拼法 (#6011) #6048 shape ADR-0087 §Addendum says the arm exists for. The exemption rests on a true statement, not on a detector miss. Right.
  • @objectstack/platform-objects patch, Clause-②: no, Metadata-protocol: none (four help-text strings). Matches.
  • @objectstack/formula patch, Clause-②: no, Metadata-protocol: none (README and docblock pointer). Matches.
  • PR body Clause-②: no (narrowing) + Metadata-protocol: narrowed agree with the spec changeset. Clause-②: line: no (narrowing) — correct for a diff that widens nothing and refuses values it accepted.

③ Boundary flags

  • Dev flag: Tier H (skills/**). Declared in the body; Governed Surface Queue Guard green; the skill file is 9032 bytes → 2258 tokens against the pinned ceiling 2259 (check-skills-token-ratchet.mjs:814), 204 → 204 lines, paid by deletion in the same paragraph. Lands by the maintainer's hand after the seat's ACCEPT. Answered.
  • open_questions[0] — "docs(spec, formula): five shipped citations point at content/docs/concepts/north-star.mdx "§8" / "§6.3", sections that page never had, and the path is renamed by #26073 #26973: ADR-0032 citations rather than a drop. Keep or drop?" Answered: A, keep. The card's "Wanted" is "the section that actually states the claim … or drop the section number"; the renamed page and docs/NORTH-STAR.md do not state it (measured above), ADR-0032's title does. A bare drop would leave the docblock claiming "no private expression DSL" without its source. The card's acceptance (git grep north-star.mdx -- packages/ → 0, positive control hits) is met either way.
  • Skills-docs: line ([Decision] pm-dispatch: which PRs owe a Skills-docs: line (the #26184 audit Part B rule draft) — protocol-kind only, protocol-kind plus narrowing, or not adopted #26369 letter B). Present, as owed by the (narrowing) arm. The skills/** half is true: the one sentence that taught newTabUrl as "a direct URL template" now teaches a backend path and points an external page at openIn. The eval half is not: 0 cases under skills/objectstack-ui/evals/ exercise newTabUrl or opensInNewTab (grep confirms), and SKILL.md:313 defines updated here as "teaches it in skills/** or the docs and the package's evals exercise it". The line's token is updated here with the gap disclosed in the same sentence ("follow-up owed if the seat wants one"). Should-fix (PR body, not the tree): rewrite to Skills-docs: follow-up owed — skills/objectstack-ui/evals/CASE.json (an eval that authors an opensInNewTab+newTabUrlaction and expects a/ path), and the seat files the card and writes #N before ACCEPT (review-checklist.md:18: 行在且属实, 缺 ⇒ REWORK). The dev's out_of_scope_findings already hands this to the seat ("carrier: seat"). Escalated to the lane seat's ACCEPT.
  • out_of_scope_findings[0] — consoleServerAction.ts:209 substitutes only the first {recordId} (String.replace with a string pattern). True on the head; no value in the tree repeats the placeholder; no renderer change by order. Noted, no carrier needed. Answered.
  • Docs-drift advisory named content/docs/protocol/objectui/actions.mdx:110 ("newTabUrl provides a zero-roundtrip new-tab target template supporting the {recordId} placeholder"). Still true under the rule, as the body says; it does not teach the / rule. Not a hits file; a hand-written docs follow-up the seat may fold into the eval card. Noted.
  • Pending gate: Test Core (3/8) is still in_progress on the head at the time of writing (50 runs: 48 success/skipped, 0 failure, 1 pending). Every other derived gate the hits touch — Build Core, Lint & Repo Gates, Check Changeset, Spec property liveness, Type Check UI, Type Check · workspace, Type Check · consumer gates, Governed Surface Queue Guard, Build Docs, Check Documentation Links — is green. The pending shard is stated as pending, not assumed green; this record's PASS is on the tree and does not stand in for it.

④ Design judgment

  • packages/spec/src/ui/action.zod.ts — against triage ruling A, consoleServerAction.ts (first consumer) and the card's acceptance. The regex is the smallest rule that makes the console's origin + newTabUrl concatenation correct by construction; the refusal is located at newTabUrl, names the path form, the reason, and the measured external-page remedy. JSDoc, .describe() and the refusal say one thing. Tried to break it: (a) //host — stays on the backend origin, correctly admitted; (b) / alone — admitted, harmless, the endpoint 404s; (c) an empty string — never reached the fast path (takesNewTabUrlFastPath requires non-empty) and is now refused, a strict improvement; (d) type:'url' + newTabUrl — still refused by the pair refine first, no double-issue conflict since both locate newTabUrl but only one fires per shape; (e) a ${ctx.recordId} written into newTabUrl — admitted if it starts with /, and the console substitutes only {recordId}, a pre-existing property the JSDoc states. The JSDoc's stored-row paragraph matches protocol.ts:28702-28705 and the three new pins. Holds. No finding.
  • packages/spec/src/ui/action.form.ts — help text says "Path on this backend, starting with / (e.g. /sso-open/{recordId})" and names the url action with Open In = new-tab; the four generated bundles carry it (en from source, zh-CN / ja-JP / es-ES hand-translated with keys unchanged; the stale-fill gate accepts any non-empty hand value). The label stays "New-tab URL" / "新标签页 URL 模板" — that is the key's own name, and the card asked the help text, not the label, to agree. Holds. No finding.
  • packages/spec/src/ui/dashboard.zod.ts — against spec(ui): the dashboard widget describe does not say that a dataset tile the server refuses as a read refusal is left out for that viewer; requiresObject keeps its install-only meaning #27068 and DashboardRenderer.tsx / DatasetWidget.tsx on the head: every clause of the docblock (outside design mode left out; server's 403 is the only verdict; design mode keeps the tile with its no-access state; requiresObject / requiresService install-only) is a line of the renderer. No key, no accept-set change; the generated dashboard.mdx follows. Triage 6456651336 ruled out a key and none was added. Holds. No finding.
  • packages/spec/src/shared/expression.zod.ts, packages/spec/src/system/environment-artifact.zod.ts, packages/spec/src/data/hook-body.zod.ts — against docs(spec, formula): five shipped citations point at content/docs/concepts/north-star.mdx "§8" / "§6.3", sections that page never had, and the path is renamed by #26073 #26973's "Wanted" and the live targets: ADR-0032 exists at the cited path and states the claim in its title; architecture-overview.mdx § "Environment Artifact" exists and states the runtime-inputs boundary; the dropped "Metadata-only runtime" pointer named a heading that exists nowhere, and the docblock keeps the claim. The {@link} → backtick change in environment-artifact.zod.ts is right: {@link} to a non-symbol path rendered as a dead link. References regenerated (check:docs green inside Lint & Repo Gates). Hold. No finding.
  • skills/objectstack-ui/rules/actions.md — against the spec it teaches on the head and the card: the only sentence that steered an author into an absolute newTabUrl now reads "a backend path starting with / ({recordId} placeholder; an external URL is refused, use openIn above)", and "above" resolves to the openIn: 'new-tab' subsection at :142-160. The example at :176-185 (newTabUrl: '/api/v1/invoice/{recordId}/pdf') already obeyed the rule. Within ceiling (2258/2259). The card carries no eval prompt and no eval exercises the key — see ③. Holds. No finding in the file. Out of scope, pre-existing, no carrier: the openIn example at :157 interpolates ${record.id} while the spec's refusal prescribes ${ctx.recordId} for a url target; not this PR's hunk, noted for the eval follow-up.

Must-fix: none. Should-fix: one, on the PR body's Skills-docs: token (③), resolved by the seat before ACCEPT.

Implemented-by: claude/project-thread-ev6wfx
Reviewed-by: session_01AavokzJ5DndAwitDXvKy4U

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment