Skip to content

feat(spec,core,cli)!: a scenario's requires is checked before it runs — unmet params or services skip it with a reason; requires.plugins retires into requires.services - #20511

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20289-requires-services
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-20289-requires-services

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20289

Clause-②: yes (narrowing)

BREAKING — scenarios[].requires.plugins is removed (a retiredKey() tombstone whose refusal names requires.services). Shipped as minor under the launch-window convention; the ADR-0087 disposition is registered qa-scenario-requires-plugins-retired, carried by the adr-0087 marker in .changeset/20289-requires-services-skip.md (the file check:adr-0087-registration reads).

Executes ruling B on the card (5863822176, batch 232, item 1, maintainer 「同意」): the fifth and last key of the qa-runner family. The four sibling keys landed in PR #20341; this PR completes the card.

What changes

  • @objectstack/spec (qa/testing.zod.ts)
    • requires.params — judged against the environment of the process running os test: each variable must be set and non-empty (an unconfigured CI secret arrives as an empty string, so empty counts as unset).
    • requires.services — NEW, z.array(CoreServiceName): each key must be declared by the target's discovery document as enabled with status === 'available' (ADR-0076 D12). A misspelled key is refused when the suite loads.
    • requires.plugins — retiredKey() tombstone. The prescription names requires.services and carries the plugin → service mapping, derived from CORE_SERVICE_PROVIDER (the provider table both discovery builders read) so it cannot name a package that does not fill the slot. This answers the ruling's confidence gap for out-of-repo authors.
    • ADR-0087: RETIRED_KEYS_BY_MAJOR[18] gains qa/TestScenario:requires.plugins; D3 semantic entry qa-scenario-requires-plugins-retired; no D2 conversion (a QA suite is a loose JSON file os test loads, never a stack collection member or a stored row — the rest-api-config-dead-keys-retired precedent).
    • Liveness: qa.scenarios.requires dead → live (evidence runner.ts#judgeRequirements, test.ts#summaryLine; producer http-adapter.ts#readTargetServices + the discovery builders, and test.ts#run for the env). state-counts.md: qa 8/1 → 9/0. README row updated.
  • @objectstack/core (qa/)
    • TestRunner judges requires before the first step, setup included. Every unmet entry is reported (params first, then services); a service reason lists what the target declares available.
    • TestResult.status: 'passed' | 'failed' | 'skipped' and, on a skip, skipped: { reason, unmet[], availableServices? }. passed stays, false on a skip.
    • TestExecutionAdapter.readTargetServices?() — optional. HttpTestAdapter answers it from the SAME memoised discovery probe its record actions use: the document is now kept instead of read for routes.data and dropped. A suite that requires no service issues no extra request (an api_call-only suite still probes nothing). An adapter without the method skips a service requirement rather than running it.
    • new TestRunner(adapter, { env }) — optional, defaults to this process's environment.
  • @objectstack/cli (os test)
    • Prints each skipped scenario as ⏭️ Scenario: NAME [ID] (skipped) with a Skipped: reason line.
    • Counts skips apart: SUCCESS: 3 scenarios passed. 1 skipped (not run, not counted as passed). With nothing skipped the summary lines are byte-identical to before.
    • A run in which every selected scenario was skipped prints No scenario ran: … instead of SUCCESS, exits 0, and exits 1 under --fail-on-empty (description and flag help updated).

Measured before building (the dispatch's mechanism assumptions)

  1. The adapter's discovery fetch — HOLDS, with one precision. HttpTestAdapter issues GET {apiBase}/discovery at most once per adapter (memoised promise), and os test builds one adapter per run. It was LAZY: fired only by the first record action, and it kept routes.data only. Both producers (metadata-protocol getDiscovery, runtime getDiscoveryInfo) emit services[name].{enabled,status} keyed by CoreServiceName members only. So no fetch is added: the requires.services judgement is a second trigger of the same memoised probe, and a run still issues at most one discovery request (pinned: 4 CLI runs against a stub → 4 discovery hits).
  2. requires read nowhere on main, ledger row dead — HOLDS (git grep over packages/core/src/qa and packages/cli/src: zero scenario.requires reads; liveness/qa.json row dead).
  3. --fail-on-empty today fails exactly two things: a glob that loaded no suite, and a --tags selection that selected zero scenarios. DESELECTED scenarios never reach the runner and appear only in the --tags … selected N of M; K deselected line; a SKIPPED scenario was selected, reached the runner and was refused by its own precondition, so it prints its own line and reason and is counted on the summary. The all-skipped case joins the same posture as the other two.
  4. Retirement route: requires is a non-strict z.object() ⇒ retiredKey() tombstone (a bare deletion would strip it silently). ADR-0087 for a key nested at scenarios[].requires.plugins: one RETIRED_KEYS_BY_MAJOR[18] entry spelled qa/TestScenario:requires.plugins (nested key of an inline block, no authorable-surface/ line of its own — the api/RestApiConfig:documentation.enabled precedent) plus one D3 semantic entry; no D2 conversion. The liveness walk classifies requires as one block, so the tombstone needs no row.

Pins (the ruling's five, plus the kit)

Ruling pin Where
an unmet services entry skips and lists the declared services core/src/qa/runner.test.ts (structured unmet + availableServices), cli/test/qa-requires-skip-run.test.ts (printed reason)
a met one runs same two files, CONTROL cases
an unmet params entry skips naming the variable same two files; the CLI run sets the variable in the child and the scenario runs
an all-skipped run is not a pass, and exits 1 under --fail-on-empty cli/test/qa-requires-skip-run.test.ts (no SUCCESS, exit 0; exit 1 strict)
a plugins key is refused at parse with the prescription spec/src/qa/testing.test.ts (code, path, prescription, tsc never), cli/test/qa-suite-schema-load.test.ts (refused at os test load)

Plus: spec/src/qa/requires-plugins-retirement.test.ts (ADR-0087 registration + the tree-scoped absence pin over packages/examples/skills/content/scripts, inside the radius @objectstack/spec already declares; registered in vitest.repo-tests.json), core/src/qa/http-adapter.test.ts (one probe answers both questions; failure readings), cli/test/qa-requires-summary.test.ts (summary line shapes).

Verification

All code runs at head 75d17a11 (the final commit; the only later-than-code commit regenerated testing.mdx, and the targeted pass ran on that same tree before committing it). Suites and builds ran under os-verify-lock; lock seconds are shared-box figures.

  • @objectstack/spec test (local): Test Files 572 passed (572) · Tests 16797 passed | 1 todo (16798).
  • @objectstack/spec test:repo: Test Files 39 passed (39) · Tests 694 passed (694) (includes the new tree-scoped pin).
  • @objectstack/core test: Test Files 59 passed (59) · Tests 1555 passed (1555); src/qa alone 69 passed.
  • @objectstack/cli, the os test files — unit tier (qa-suite-schema-load, qa-requires-summary, qa-tags-selection, vitest-tiers-partition): 46 passed; integration tier (qa-requires-skip-run, qa-names-and-tags-run, spawning bin/run-dev.js against a node:http stub): 15 passed. The rest of the cli suite is declared to CI (pnpm test runs both tiers); qa-empty-glob-exit-code.e2e.test.ts was named and matched no vitest project in this package — NOT MEASURED here, reason: not a member of either project.
  • typecheck exit 0 for @objectstack/spec, @objectstack/core, @objectstack/cli (each including check:test-typecheck OK). Reverse check of the type channel: the @ts-expect-error on an authored requires.plugins in spec/src/qa/testing.test.ts is consumed (a stale .d.ts would leave it unused and red), and core/cli tests typecheck requires.services, a key only the rebuilt .d.ts carries.
  • check:generated: first run ✗ 1 of 15 artifact(s) stale (check:docs); --fix regenerated content/docs/references/qa/testing.mdx; re-run ✓ All 15 generated artifacts are up to date.
  • check:liveness: qa 9 classified (live 9); ✓ packages/spec/liveness/state-counts.md is current.
  • Ablation (scripts/ablation-replace.mjs, committed tree): runner.ts anchor if (unmet.length === 0) return undefined; flipped to a greater-or-equal-zero comparison (never skip). Anchor 1 → 0, blob 77e3f4c5a76c → 5df21094f9fd. runner.test.ts: 7 failed | 23 passed (30) — exactly the seven skip-asserting pins; the met-service CONTROL, the no-probe pin, the failed-status pin and the 20 pre-existing tests stayed green. Expected direction: red; observed: red. Restore: blob 77e3f4c5a76c == HEAD, git diff HEAD empty. No dist leg: the subject is imported relatively from src.
  • Gates: dispatch-gates --commands --repo objectstack-ai/objectstack at 75d17a11 derived 122 families; all 122 ran with exit codes recorded to disk; --ran: 122 derived, 122 run, 0 NOT-MEASURED, 0 UNRUN. 120 exit 0. Four first answered exit 3 (PREREQUISITE NOT MET: check:skill-examples, check:dual-build-cjs-loads, check:i18n-coverage, check:type-check-debt) and exit 0 after their named prerequisites were built. Two exit 1, neither this diff's:
    • check-empty-changeset --base origin/main — the DELIBERATE CORRECTION of the pending note (section below). Expected red.
    • check:platform-checklist — areas/identity-auth.json: ABSENT SYMBOL packages/plugins/plugin-auth/src/auth-plugin.ts#twoFactor. The same single problem at the base fc0db22b and at current main 2b24b8b8 (control runs in a detached worktree); this diff touches neither file.
  • Lint, a measured narrowing: the 25 changed paths, asked of eslint.config.mjs itself: 16 linted by the config, 9 ignored (.md/.mdx/.json). eslint --no-inline-config --format json over the 16: 16 results, 0 errors, 0 warnings. parserOptions.project / projectService are null for all 16 (no type-aware linting), so no untouched file's verdict can move. The repo-wide pnpm lint is CI's.
  • Mergeability: a driver-free merge-tree of this head onto main 2b24b8b8 is clean (the one shared file, migrations/registry.ts, is disjoint prose on main's side). main is not merged in; CI validates the merge ref.
  • Not measured: a booted showcase. The skip path was driven end to end against a stub that answers discovery the way both producers do.

Texts this PR would otherwise falsify, corrected in the same change

  • content/docs/deployment/cli.mdx §os test: said requires is not checked; now documents both keys, the skip line and the exit posture.
  • docs/qa/platform-checklist/areas/cli.json (cli.qa-suite-execution): knownGap and the qa.json source note; revision 5 → 6.
  • packages/spec/scripts/liveness/check-liveness.mts header comment: named scenario.requires as the one key still unread.
  • A pending release note — a deliberate correction, confirmation requested here. .changeset/20289-os-test-names-tags.md (PR feat(qa): os test prints suite and scenario names and selects scenarios by --tags #20341's, not yet released) ends with a @objectstack/spec bullet saying TestScenario.requires "is still checked by nothing … a scenario that declares a plugin the target lacks still runs". This PR makes that false in the same release, so the bullet now says the ledger moved the four keys to live and that requires is checked in this release under its own note. Nothing else in that note changed. This is the DELIBERATE CORRECTION class check-empty-changeset.mjs names: Check Changeset is expected to stay RED on that row (it is not a required context), skip-changeset is not applied, and the correction needs a person's confirmation on this PR.

Acceptance notes

  • requires.services is closed over CoreServiceName. The ruling says "array of service keys"; both discovery producers key services by CoreServiceName members only, so the closed enum loses nothing and turns a misspelling into a parse refusal instead of a runtime skip — the contract-tightening direction. The ruling's "a misspelling skips loudly" still holds for a valid key the target does not declare. If the seat reads the ruling as an open vocabulary, it is a one-line change to z.array(z.string()).
  • TestResult.passed is kept (false on a skip) beside the new status. A consumer that counts !passed as a failure reads a skip as a failure — loud, never a silent pass; the changeset tells it to read status.
  • Out of scope, unchanged: a suite whose scenarios is [] (no --tags, nothing skipped) still prints SUCCESS: All 0 scenarios passed. and exits 0 under --fail-on-empty — the all-skipped posture does not widen to it.
  • @objectstack/core and @objectstack/cli are minor (additive public fields and an optional interface method; new output and exit posture); @objectstack/spec is minor with the BREAKING banner. All three share the changesets fixed group.

Generated by Claude Code

… skip with a reason; retire requires.plugins (WIP)

Ruling B on the qa-runner family's requires key: requires.params is judged
against the environment of the process running os test, requires.services
(new, closed over CoreServiceName) against the target's discovery services
map, and an unmet entry makes the scenario SKIPPED with its reason. The
retired requires.plugins is a retiredKey() tombstone whose prescription
names requires.services.

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
… and the plugins refusal; changeset

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
…the plugins tombstone

Claude-Session: https://claude.ai/code/session_01ARcDurZ5j34RdqsGgc4jgH
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/cli, @objectstack/core, @objectstack/spec, touching 27 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/spec/liveness/README.md, packages/spec/liveness/qa.json, packages/spec/liveness/state-counts.md, …), 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/deployment/cli.mdx (via os test (command, read off packages/cli/src/commands/test.ts))
  • content/docs/protocol/kernel/lifecycle.mdx (via os test (command, read off packages/cli/src/commands/test.ts))

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

  • content/docs/releases/v17/17-5.mdx (via os test (command, read off packages/cli/src/commands/test.ts))

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
  • 5 changed file(s) yielded no anchor (packages/spec/liveness/README.md, packages/spec/liveness/qa.json, packages/spec/liveness/state-counts.md, …) — pages documenting those are invisible to this run
  • 14 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 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 — 147 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 4a1df19656bf5b12258ef20e5aa9e8d99c3461bd → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 74ed016dfacf857e31425a811ad38eee063f7dde — the merge of head 75d17a115ee806c21e916caf4c3c3cbce7e32e56 into base 4a1df19656bf5b12258ef20e5aa9e8d99c3461bd, 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 74ed016dfacf857e31425a811ad38eee063f7dde && git checkout 74ed016dfacf857e31425a811ad38eee063f7dde
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4a1df19656bf5b12258ef20e5aa9e8d99c3461bd 75d17a115ee806c21e916caf4c3c3cbce7e32e56 && git checkout -B drift-repro 4a1df19656bf5b12258ef20e5aa9e8d99c3461bd && git merge --no-ff 75d17a115ee806c21e916caf4c3c3cbce7e32e56

node scripts/docs-audit/affected-docs.mjs --json 4a1df19656bf5b12258ef20e5aa9e8d99c3461bd

⚠️ 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 4a1df19656bf5b12258ef20e5aa9e8d99c3461bd → 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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 75d17a115ee806c21e916caf4c3c3cbce7e32e56
Local-runs: none

Inputs: card #20289 (body and all 12 comments — triage 5859588706, the four-key round's claim/reports/REWORK/ACCEPT/release, the decision box 5863075827, ruling 5863822176 letter B, this round's claim 5876608230 and dev report 5878229797); PR #20511 (body, 25-file list, net diff origin/main...75d17a11, merge base fc0db22b, +1536/−70); the head's check-runs (read four times, last read stated at the foot). Reads on origin/main (4a1df196) only to verify claims: the two discovery producers, CoreServiceName, the changeset and checklist gates' sources, symbol-anchors.mjs, cross-package-test-inputs.mjs, the spec-property-retirement skill. Nothing built, run or re-run.

① Derived judgments

Ruling B, parameter by parameter — each executed, each right.

  • requires.plugins → retiredKey() tombstone inside the non-strict requires block (testing.zod.ts): key-first prescription, was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) (spec is at 17.4.0; 14 neighbouring tombstones on main say 17.5.0), why it was inert, Delete the key and name the service … in requires.services, and the plugin → service mapping derived at module load from CORE_SERVICE_PROVIDER with file-storage left out (the ruling's confidence gap, closed). No os migrate meta sentence — right: there is no D2 conversion for it to list. Right.
  • requires.services judged against discovery services[name]: met only when enabled === true AND status === 'available' (runner.ts#isAvailable); degraded is unmet, undeclared is unmet, an unreadable discovery makes every service requirement unmet with the probe's own reason (answered 404 / could not be reached / carried no services map). Right.
  • requires.params judged against the environment of the process running os test: test.ts builds TestRunner(adapter, { env: process.env }), the runner defaults to process.env; unset OR empty is unmet (the empty-CI-secret reading, documented in the changeset and cli.mdx). Right.
  • SKIPPED in @objectstack/core: TestResult.status: 'passed' | 'failed' | 'skipped', skipped: { reason, unmet[], availableServices? }, passed: false, steps: [], judged before setup runs; runSuite stamps suiteName on a skipped result too. Right.
  • os test: ⏭️ Scenario: NAME [ID] (skipped) plus a Skipped: reason line; summaryLine counts skips apart and is byte-identical when nothing was skipped; an all-skipped run prints No scenario ran: all N selected scenarios were skipped on unmet requirements. instead of SUCCESS, exits 0, exits 1 under --fail-on-empty; the flag's help names all three empty cases. Right.
  • Ledger qa.scenarios.requires dead → live, evidence runner.ts#judgeRequirements and test.ts#summaryLine, producer http-adapter.ts#readTargetServices + both discovery builders + test.ts#run; state-counts.md qa 9/0; Spec property liveness green on the head. Right.

The five pins — each present and load-bearing.

  1. Unmet services skips and lists the declared services — runner.test.ts (structured unmet, availableServices: ['auth', 'data'] with metadata degraded and ai disabled excluded) and qa-requires-skip-run.test.ts (the printed line, regex-anchored to the exact sentence).
  2. A met one runs — CONTROL in both files (executed: ['/setup', '/api/v1/health']; ✅ Scenario: Needs data [svc-met]).
  3. Unmet params skips naming the variable — both files; the CLI pin deletes the variable from the child's env so an operator's shell cannot decide the run, then sets it and the scenario runs.
  4. An all-skipped run is not a pass — no SUCCESS, exit 0; --fail-on-empty ⇒ exit 1. The fixture makes the exit status its own evidence: every gated scenario asserts degraded, which the stub never answers, so a 0 is only possible if the scenario never ran.
  5. plugins refused at parse with the prescription — testing.test.ts (path requires.plugins and scenarios.0.requires.plugins, invalid_type, key-first, the mapping iterated over every non-null CORE_SERVICE_PROVIDER row, the @ts-expect-error consumed) and qa-suite-schema-load.test.ts (refused at os test load, located, naming the replacement).

"One reader over one existing contract, no new served surface" — verified on the diff. spec/api/discovery.zod.ts is untouched; no route, no field. HttpTestAdapter now memoises the whole discovery read (discoveryPromise); dataMount() chains on it and readTargetServices() reads document.services off the same read. One fetch site, same URL, same bearer header, same bare-or-{ data } unwrapping, same status handling (response.ok became an explicit 2xx test); the pre-existing laziness is kept, so an api_call-only suite with no service requirement still probes nothing. Pinned twice: http-adapter.test.ts (one discovery call serves a record action and the services read; the mount warning is not printed by a services-only read) and 4 CLI runs = 4 discovery hits where the mixed suite has two service-requiring scenarios. Right.

Divergence — z.array(CoreServiceName): INSIDE the ruling, a stricter spelling of the same contract. Producer claim verified on origin/main: metadata-protocol/src/protocol.ts#getDiscovery writes metadata, data, the 13 SERVICE_CONFIG rows and the file-storage mirror; runtime/src/http-dispatcher.ts#getDiscoveryInfo writes a fixed 16-key literal. Both key sets are exactly the 16 CoreServiceName members, and neither producer has a path that adds a key (mcp and protocol are resolved for capabilities, never written into services). DiscoverySchema.services is typed as an open string record but describes itself "keyed by CoreServiceName"; the dispatcher schema types service as CoreServiceName. So "array of service keys" spelled as CoreServiceName names the same set. What an open array would let through that the enum refuses: a misspelling (analytic), a routes-map spelling (notifications, meta), a package name, a kernel-only registration (mcp, email), a retired slot (workflow) — no real producer can ever answer any of them, so an open array would turn each into a permanent skip, which is a typo report, not a precondition. The one thing an open array admits that a producer could someday answer: a slot added in a later spec, required by a suite run under an older os test — a cross-version case bounded by the fixed release group, the same coupling every CoreServiceName consumer already carries. The ruling's AI-safety reading still holds where it can apply: a valid key the target declares but does not serve skips loudly and lists what is served (ai on the stub, pinned); a non-vocabulary key is now refused earlier, at load, with the vocabulary in the zod error — louder, not quieter, and nothing passes silently. Not a re-ruling; no return to z.array(z.string()) is owed. The changeset, cli.mdx, the schema describes and the regenerated reference state the closed vocabulary consistently.

TestResult — additive for every reader. New status, optional skipped, passed kept and false on a skip; TestResultStatus, UnmetRequirement, SkipReport, TestRunnerOptions, TargetServices are public through core's export *. status is a required field, so a third-party CONSTRUCTOR of TestResult literals would have to add it — none exists in-repo (TestRunner is the only producer) and that is within minor; the changeset says "gains status" without saying it is required — a wording nit, not a gate. In-repo readers of passed: only cli/src/commands/test.ts, which now branches on status === 'skipped' before reading passed; the other .passed hits on main (lint.ts, metadata-eval.ts, migration-flag.ts) are other result types. No reader mis-counts a skip. objectui: no TestResult reader and no requires.plugins authoring. TestExecutionAdapter.readTargetServices?() is optional and HttpTestAdapter is the only in-repo implementor; TestRunner(adapter, options = {}) is additive.

Retirement route against the spec-property-retirement skill. Non-strict z.object() ⇒ retiredKey() (a bare deletion would strip the key in silence) — right. RETIRED_KEYS_BY_MAJOR[18] literal qa/TestScenario:requires.plugins — the api/RestApiConfig:documentation.enabled nested-key precedent exists on main (registry.ts:17966); authorable-surface/qa.json carries qa/TestScenario:requires as one block-level line, so no [RETIRED] baseline line is owed and a clean check:generated is the consistent reading. One D3 semantic entry for the family (qa-scenario-requires-plugins-retired, step 18, surface/replacement/reason/acceptanceCriteria all filled, the prescription and mapping repeated for os migrate meta and the upgrade guide). No D2 — right: a QA suite is a loose file os test loads, never a stack collection member or a stored row, so stripKeys would have nothing to walk (the skill's "no source to rewrite ⇒ D3 only" arm; the retirement pin asserts no conversion id names the key). Ledger: the walk classifies requires as one row, kept live with the tombstone recorded in its note — the liveness gate is green on that shape. Tree-scoped absence pin: structural matcher (an object that is the VALUE of a requires key and carries plugins), anti-vacuity asserted, the kit excluded by name with reasons and no allowlist file, walking packages/examples/skills/content/scripts with the extensions @objectstack/spec already declares in cross-package-test-inputs.mjs — the same WALK_ROOTS the rest_api, rls-tags, view-item and compliance-families pins use — and registered in vitest.repo-tests.json. Right.

Ablation and lint — judged from the code. Flipping if (unmet.length === 0) return undefined; to a greater-or-equal comparison makes judgeRequirements never return a report. Of the 10 new runner.test.ts cases exactly 7 assert a skip (unmet services; degraded/undeclared; unmet params; every entry reported; unreadable target; adapter without the method; runSuite mixed), 3 stay green (met CONTROL; asks-only-when-required; failed-says-failed) and the 20 pre-existing are untouched ⇒ 7 red / 23 green of 30, matching the report; the CLI pins would go red too (svc-unmet would run and fail on degraded). The pins catch the regression they claim. Lint: 16 TS/MTS files + 9 md/mdx/json = the 25 paths; nothing in the diff can move an untouched file's verdict.

Texts this PR falsifies — corrected, each true after the PR. cli.mdx §os test (both keys, the skip line matching the code's strings, the exit posture); cli.json knownGap + the qa.json#tags source note (revision 5 → 6, history appended, the revision-5 entry preserved; search named as a slot the open framework never fills, consistent with CORE_SERVICE_PROVIDER); check-liveness.mts header. Generated testing.mdx regenerated (Build Docs, Check Documentation Links, Flag docs affected by code changes green).

DELIBERATE CORRECTION — .changeset/20289-os-test-names-tags.md (PR #20341's pending note; present and unchanged on origin/main since the merge base, so still pending). One line changed.

  • Old bullet: "@objectstack/spec: TestScenario.requires (params, plugins) is still checked by nothing — its describe() now says NOT CHECKED instead of reading as a guard, so a scenario that declares a plugin the target lacks still runs, and the unmet requirement surfaces only as whatever failure it causes, if any. The liveness ledger (liveness/qa.json) moves the four keys above to live, citing their readers."
  • New bullet: "@objectstack/spec: the liveness ledger (liveness/qa.json) moves the four keys above to live, citing their readers. TestScenario.requires, the family's fifth key, is checked in this same release and has its own note: an unmet params or services entry skips the scenario with its reason, and requires.plugins is retired into requires.services."
  • The new bullet is TRUE after this PR: the ledger sentence is feat(qa): os test prints suite and scenario names and selects scenarios by --tags #20341's fact and unchanged; the requires sentence is this PR's fact, carried by its own note in the same pending set. The old bullet WOULD SHIP FALSE beside this PR's note: "still checked by nothing" and "a scenario that declares a plugin the target lacks still runs" are both false in a release that also carries this PR, and the second sends an upgrading reader toward a key the same CHANGELOG says is refused at load. Nothing else in the note moved.
  • The gate: scripts/check-empty-changeset.mjs names this class DELIBERATE CORRECTION and prescribes do NOT restore it -- say so on the PR and get it confirmed (it "stays red either way"); the job that runs it, Check Changeset in pr-automation.yml, triggers on pull_request only — no merge_group: — and its own remedy text says it is not a required context and the red is the point. On this head its single failure annotation is this file's foreign-changeset row and nothing else; skip-changeset is not applied; the PR body asks for the confirmation. Named and judged here: the red is of the accepted class.

check:platform-checklist red — main's, not this diff's. areas/identity-auth.json:1599 cites packages/plugins/plugin-auth/src/auth-plugin.ts#twoFactor; on origin/main that file has twoFactor only inside a comment and as an inline nested key (plugins: { twoFactor: true }), and scripts/symbol-anchors.mjs#scriptSymbolClass resolves an object-literal key only when it opens a line — which it did until 7d630889 (#20429) moved it inline. So the ABSENT SYMBOL is reproducible from the resolver's rule on main, caused by #20429, and this diff touches neither file. The gate is kept out of per-PR CI by design (lint.yml runs only the watchdog self-test; platform-checklist-watchdog.yml is the standing caller on main). Right.

merge-tree. git merge-tree --write-tree --name-only origin/main 75d17a11 with origin/main at 4a1df196 (past the merge base fc0db22b): tree 291caab9cb792696d889b4d6a150cf6d6f6a41e0, exit 0, no conflicted paths.

② Semver level

.changeset/20289-requires-services-skip.md: @objectstack/spec: minor, @objectstack/core: minor, @objectstack/cli: minor; Clause-②: yes (narrowing) as a standalone line; a BREAKING banner; the FROM → TO block and the one-line fix (requires.plugins: ["package"] → requires.services: ["service"] with the mapping); the retirement kit paragraph; and the ADR-0087 marker as the HTML-comment line adr-0087: registered qa-scenario-requires-plugins-retired at the foot — the changeset this PR ADDS is where check-adr-0087-registration.mjs reads it, and the registered arm's id resolves to the step-18 entry this same diff adds. Matches what the diff publishes: spec narrows an authorable key (plugins → never) and adds one (services) ⇒ yes (narrowing), breaking-ness carried by the banner and the disposition, minor under the launch-window convention (check-changeset-no-major.mjs refuses major; AGENTS.md §3 and the skill say minor + banner, never major) — the right spelling. core: additive public fields and types, an optional interface method, optional constructor options ⇒ minor, right. cli: new output lines, a new exit posture under an opt-in flag, help text ⇒ minor, right. All three share the fixed group. skip-changeset correctly not applied. PR body: Fixes #20289 first line and Clause-②: yes (narrowing) standalone on line 3, agreeing with the changeset.

Clause-②: yes (narrowing)

Note for the seat: on this head the Check Changeset job's steps 13–15 (ADR-0087 disposition, allow-major re-read, no-major guard) are skipped behind the deliberate red at step 12, so CI did not exercise those two gates; the judgment above is from the gates' sources against the changeset text (marker form and placement, registered id added by this diff, no major level).

③ Boundary flags

Dev flags (deviations), each answered:

  1. packages/core/src/qa/adapter.ts — the optional readTargetServices?() and TargetServices are the only way the runner receives the discovery map without coupling it to HttpTestAdapter; a necessary consequence of "one reader over the existing probe", not a widening.
  2. http-adapter.test.ts, qa-requires-skip-run.test.ts, qa-requires-summary.test.ts, qa-suite-schema-load.test.ts — the ruling's pins and the claim's "plus its tests"; necessary.
  3. requires-plugins-retirement.test.ts + its vitest.repo-tests.json line — the skill's tree-scoped absence pin with a declared radius; a retirement without it is not finished. Necessary.
  4. cli.mdx, cli.json (5 → 6), check-liveness.mts header — the class the feat(qa): os test prints suite and scenario names and selects scenarios by --tags #20341 REWORK (5861125164) named: the PR that changes the truth corrects its text. Necessary.
  5. .changeset/20289-os-test-names-tags.md one-bullet rewrite — necessary (judged in ①). The dev's own condition holds today: the note is still pending on origin/main; if a release consumes it before this lands the hunk conflicts and must be dropped at merge.
  6. Closed enum over CoreServiceName — inside the ruling (①); named on the PR with the one-line reversal on offer; not a widening.
  7. ADR-0087 marker in the changeset, disposition in words on the PR body — right placement; the gate reads the changeset, and keeping the comment form off the body is sound.
  8. Commit trailers model-free — verified on all four branch commits.
  9. origin/main not merged in — merge-tree clean at the current tip; CI validates the merge ref.
  10. Container restart, verify-lock holds, cleanup — process notes; the pushed head carries the regenerated reference page and ledger counts; no contract effect.

open_questions: empty — right; the ruling closed the only one and nothing here needs escalation.

out_of_scope_findings:

  • (a) platform-checklist ABSENT SYMBOL — right: class a, main's red, producer named (7d630889, fix(plugin-auth): a refused auth setting no longer drops the settings saved with it #20429), reproducible from the resolver's rule, outside this diff's files; the carrier is the checklist sweep or the watchdog, not this PR.
  • (b) an empty scenarios: [] suite prints SUCCESS: All 0 scenarios passed. and exits 0 under --fail-on-empty — right as a finding: pre-existing, reproducible from the unchanged final else branch (summaryLine(0, 0, 0)), and the all-skipped posture deliberately does not widen to it; the flag's help names exactly the three cases it fails, so no text is false. carrier: none is acceptable for a noted pre-existing gap; the cli.qa-suite-execution knownGap is the natural carrier if it is ever filed.

Check-runs on 75d17a11 (34 runs; last read 2026-09-28T21:05Z, about twenty minutes after they started): 29 success, 2 roster skips (Console Pin Gate, Packed-tarball smoke (opt-in)), 1 failure — Check Changeset, the deliberate red judged above; no other red. Green: Spec property liveness, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Type Check · workspace, TypeScript Type Check, Build Core, Build Docs, Test Core (1/6, 2/6, 3/6, 4/6, 6/6), Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Check PR Size, Check Documentation Links, Flag docs affected by code changes, Governed Surface Queue Guard, the three claim/closing guards, Auto Label, filter. Still running at the last read: Test Core (5/6) and Lint & Repo Gates — judged on the rest; the seat lets both converge before enqueue (the latter carries check:cross-package-test-inputs, check:api-surface and the pm-record gates; nothing in this record rests on a claim only those two would answer, and a red there would be a new fact this record does not cover).

Implemented-by: claude/issue-20289-requires-services
Reviewed-by: session_01ARcDurZ5j34RdqsGgc4jgH

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 21:17
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 0bbe400 Sep 28, 2026
36 of 37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20289-requires-services branch September 28, 2026 21:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

qa: os test prints suite and scenario names, filters by tags, and skips on unmet requires (5 keys)

2 participants