Skip to content

Commit f397608

Browse files
fix(cli)!: os verify runs the author-time rules first, and a stack they refuse fails verify with the findings os validate reports (#21364)
Fixes #21323 Clause-②: yes (narrowing) `os verify` now runs the author-time rules first, through the pipeline `os validate` uses, and a stack they refuse fails `verify` with the findings `os validate` reports — before the runtime stage boots anything. The narrowing: `verify` newly exits 1 on a stack the author-time rule registry refuses (or that does not parse against the protocol after lowering); `os build` already refuses every such stack. The widening: `os verify --json` gains an `errors` key on that new failure exit. ## What changed - `packages/cli/src/utils/author-time-rules.ts` (new) — `judgeAuthorTimeRules(command, config, configDir)`: the stage `os validate` runs, step for step, through the same functions — `normalizeStackInput`, `lowerCallables` before the parse, `ObjectStackDefinitionSchema.safeParse`, `resolveJsxGateManifest` beside the config, the union run over `authoringRuleUnionStack` tiers with `stackFilterJudge` and the lowered hook refs, then `runPerPackageAuthoringRules`. It names no rule and lists no rules: which rules run is `authoringRulesFor(command)` in `@objectstack/lint`'s one table. Prints nothing; returns `{ refusal, advisories }`. - `packages/cli/src/commands/verify.ts` — stage 1 runs right after `loadConfig`, before `bootStack`. Door: `VERIFY_RULE_COMMAND = 'validate'`. - text face: a step line, then either `✓ Author-time rules passed (N rules)` (plus an advisory count pointing at `os validate`) or `✗ Author-time rules failed (N issues) — the runtime stage did not run` and each finding with its rule and location; exit 1. - `--json`: the command's existing failure envelope (`error`, compact, via `emitJson`) plus one new key, `errors`, carrying the findings in the shape `os validate --json` carries them under `errors` (rule findings, with `package` on a per-package one; Zod issues on a schema refusal). The report of a run that reaches the runtime stage is unchanged. - `content/docs/deployment/cli.mdx` — `### Quality` gains a table row and a `#### os verify` entry: the two stages, the exit mapping, the three `--json` shapes, and what stage 1 does not cover (package docs, capability providers, picklist-reference and view-container-name checks, `--strict` stay `os validate`'s). - `.changeset/21323-verify-author-time-rules.md` — `@objectstack/cli` `minor` (BREAKING narrowing under the launch-window convention), ADR-0087 disposition `not-required (no-migration-prescription)`. ## Why the stage is not lifted out of `validate.ts` / `compile.ts` The dispatch route was to lift the `runAuthoringRules` segment of `validate.ts` (and `compile.ts`) into one shared function all doors call. Measured before writing it, four existing source-scan pins hold each door's wiring in that door's own file, so the lift would move the text they read: - `packages/lint/src/authoring-rule-wiring.test.ts` — each of `validate.ts` / `compile.ts` / `lint.ts` must contain a `runAuthoringRules(` call; - `packages/cli/test/validate-build-gate-parity.test.ts` — that call's `normalized` / `parsed` tiers must resolve, by `const` bindings in the same file, to `authoringRuleUnionStack(`; every call site in `compile.ts` / `validate.ts` must sit in a ledger; - `packages/cli/test/lint-per-package-authoring-seam.test.ts` — each door must contain a `runPerPackageAuthoringRules(` call; - `packages/cli/src/utils/sdui-manifest.test.ts` — each door must contain exactly one `resolveJsxGateManifest(…, dirname(absolutePath));`. Retargeting them is outside this card's file surface (one is in `@objectstack/lint`), and the doors' output interleaves with the stage (step lines, the JSX notice, an exit between the union run and the per-package pass), which a function returning one verdict cannot reproduce byte for byte. So `validate.ts` and `compile.ts` are untouched, and the shared pipeline lives in the new util that `os verify` calls. What holds that copy of the wiring to the doors is behaviour: the spawned pin requires `os verify --json`'s `errors` to deep-equal `os validate --json`'s, and the util's unit pin covers the union fold and the per-package pass, which a single-package stack cannot tell apart. ## Real repro (scaffold from the in-repo `create-objectstack`, plus a small task app) The card's three planted mistakes (lookup to `tasks_app_projects`, action `visible: 'done != true'`, list column `{ field: 'priority' }`), and a clean control with them corrected. Before = CLI built at `222ecc27f9`; after = CLI built at this branch; identical inputs. | app | command | before | after | |---|---|---|---| | planted | `os validate` | 1 — the three findings | 1 — the three findings | | planted | `os build` | 1 — the three | 1 — the three | | planted | `os lint` | 1 — the three, as errors | 1 — the three, as errors | | planted | `objectstack verify` | **0** — `✓ verify passed — no runtime failures` | **1** — `✗ Author-time rules failed (3 issues) — the runtime stage did not run`, the three findings, no boot | | planted | `verify --json` | 0, report polluted by boot logs | 1, one document `{ error, errors }`; `errors` deep-equals `validate --json`'s `errors` | | clean | all four | 0 | 0 (`✓ Author-time rules passed (49 rules)`, `6 advisory finding(s)`, then `✓ verify passed`) | `os validate` / `os build` / `os lint` text and `os validate --json` outputs are identical before and after on both apps (timings and absolute paths normalized). ## Tests All readings on `d3e368d5ea` (this branch after merging `origin/main` at `6c5bef5f4e`) unless noted. - New pin `packages/cli/test/verify-author-time-stage.test.ts` (integration tier: spawns the CLI): planted stack fails `verify --json` with `errors` deep-equal to `validate --json`'s, and one JSON document on stdout; planted stack fails the text face naming each rule; clean control passes both stages. Red before the fix (planted `verify` exit 0 on both faces), green after: 3/3. - New pin `packages/cli/src/utils/author-time-rules.test.ts` (unit): door is `'validate'`; option-B stack (definitions only in `packages[]`) is refused through the union fold, control passes; per-package-only finding reaches the verdict; a stack that does not parse is refused. 4/4. - `@objectstack/cli` unit project: 244/245 files at `5d0486c67d`; the one red was `test/docs-cli-enumeration-parity.test.ts` refusing an ungoverned `**Options.**` table in the new docs entry — the flags moved into the stage prose (`d3e368d5ea`), and that file plus the other unit files reading `cli.mdx` (`init.test.ts`, `create-example-retired-docs-parity.test.ts`) re-ran green: 3 files / 87 tests. - `pnpm --filter @objectstack/cli typecheck` (tsc + test-layer debt ratchet): exit 0, debt unchanged. - Integration tier, narrowed to the doors this diff touches: the new pin, `authoring-rule-command-parity`, `build-text-face-advisory-count`, `build-view-container-name`, `lint-per-package-authoring-parity`, `union-fold-command-parity`, `validate-per-package-authoring-parity`, `validate-view-container-name`: 8 files / 48 tests green. Nightly `config-miss-stdout-purity.e2e` (its family includes `verify`): 174/174. The rest of the integration tier is declared to CI. - Gates: `dispatch-gates --commands` derived 93 commands on this tree; all 93 run on `d3e368d5ea`, all exit 0; `--ran` reconciliation: 93 run, 0 NOT-MEASURED (derived from recorded exit codes). - `pnpm lint` (full repo, `eslint . --no-inline-config`): exit 0 on `d3e368d5ea`. ### Ablations (fix committed first; each leg through `scripts/ablation-replace.mjs`, restore proven blob == HEAD and `git diff HEAD` empty) - A1 `verify.ts`: the stage-1 call replaced by a marker statement → `pnpm --filter @objectstack/cli build` → `ablation-dist-preflight` found the marker in `dist/commands/verify.js` → spawned pin red 3/3. Restore → rebuild → preflight `--absent` (marker in 0 of 564 built files, tree clean) → pin green 3/3. The CLI child resolves commands from `dist/` here (see Acceptance notes), hence the rebuild per leg. - A2 util: tiers handed unfolded → only the option-B case red. - A3 util: per-package pass skipped → only the per-package case red. - A4 util: schema refusal returns `null` → only the schema case red. - A5 util: door `'build'` → only the door case red. ## Acceptance notes - `bin/run-dev.js` (and so every spawned CLI pin in this package) resolves commands from `packages/cli/dist`, not `src/`, in this container: oclif's tsx registration fails on tsx 4.23.15 (`Could not find tsx. Skipping tsx registration`). That is the area issue #21308 tracks; the new pin is correct either way in CI, which builds before testing, but a local run needs `pnpm --filter @objectstack/cli build` first. - `os verify --json` on a run that reaches the runtime stage still carries boot log lines on stdout — that is issue #21324, queued behind this card and not touched here. A stage-1 refusal is one clean document because nothing boots. - Stage 1 is the rule registry, not all of `os validate`: a stack `os validate` refuses only for package docs, a capability provider, a picklist reference or a view-container name (the last one `os serve` also refuses at boot) can still reach the runtime stage. The docs entry says so. - `packages/cli/README.md` carries no `os verify` row, so nothing there went stale. - The `**Options.**` table for `os verify` was left out: the docs pin binds every such table to the oclif declarations through its `GOVERNED` map (`test/docs-cli-enumeration-parity.test.ts`), which is outside this card's surface; adding `verify` there would also need a number word for zero positionals. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fbe2deb commit f397608

6 files changed

Lines changed: 683 additions & 6 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
fix(cli)!: `os verify` runs the author-time rules first, and a stack they refuse fails `verify` with the findings `os validate` reports (#21323)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a CLI command's verdict, not a declaration: os verify now refuses, before it boots anything, a stack the author-time rule registry refuses or that does not parse against the protocol schema. No authorable key, spelling, export or stored shape moves: every stack parses and loads exactly as before, os validate and os build answer exactly as before, and nothing on the metadata load path is read or rewritten. What an author does about a refusal is fix the finding the message names, which os validate and os build already report for the same stack, so there is no rewrite a ledger entry could carry. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a command's verdict (not registered / already-registered); and the change is CLI behaviour, not a TypeScript declaration (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING** — `os verify` narrows what it passes. It ships as `minor` under the launch-window convention for accept-set narrowings.
12+
13+
**What was accepted before.** `os verify` booted the app and exercised CRUD round-trip fidelity and, with `--rls`, the RLS invariant — and nothing else. A stack carrying a lookup to an object that does not exist, an action `visible` expression naming a field without `record.`, or a list column naming no field booted, round-tripped its records and printed `✓ verify passed` at exit 0, while `os validate`, `os build` and `os lint` all refused it. The documented done-bar ("`objectstack verify` is green") was green on a stack the build refuses to ship.
14+
15+
**What is refused now.** `os verify` runs two stages. The first is the author-time rule registry `os validate` runs, over the stack prepared the way `os validate` prepares it: normalized, inline handlers lowered, parsed against the protocol schema, the SDUI manifest read beside the config, judged whole and then once per package of a multi-package artifact. A gating finding, or a stack that does not parse, exits 1 with those findings and the runtime stage never starts:
16+
17+
- text face: `✗ Author-time rules failed (N issues) — the runtime stage did not run`, then each finding with its rule and location (the per-package and schema refusals have their own sentence);
18+
- `--json`: the command's failure envelope, `error` (the sentence), plus a new key, `errors`, carrying the findings in the shape `os validate --json` carries them under `errors` — rule findings (with `package` on a per-package one), or the schema issues.
19+
20+
Advisories never fail the stage; the text face counts them and points at `os validate`. On a passing stack the text face prints one step line and `✓ Author-time rules passed (N rules)` before the runtime stage, and the `--json` report of a run that reaches the runtime stage is unchanged.
21+
22+
**Who is affected.** Only a stack `os build` already refuses: the first stage runs the same gating rules over the same prepared stack, so every stack it refuses, `os build` refuses too. The remedy is the one `os validate` prints for each finding. Measured with this branch's CLI over the examples at `222ecc27f9` (unchanged on this branch): `os validate` exits 0 on `examples/app-todo`, `examples/app-crm`, `examples/app-showcase` and `examples/app-multi-package`, so none of the four is refused by the new stage.

‎content/docs/deployment/cli.mdx‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,6 +1614,7 @@ emits a scoped, publishable `@objectstack/plugin-<name>`, and it lands under
16141614
| Command | Description |
16151615
|---------|-------------|
16161616
| `os lint [config]` | Every author-time gate `validate`/`build` run, plus style and convention checks |
1617+
| `os verify` | The author-time rules `validate` runs, then boot the app in-process and verify it through the real HTTP stack |
16171618
| `os test [files]` | Run Quality Protocol test scenarios against a running server |
16181619
| `os doctor` | Check development environment health |
16191620
@@ -1679,6 +1680,62 @@ the flag was in effect), `failing` (the count the exit code was read from —
16791680
`errors`, or `errors + warnings` under `--strict`) and `passed` (`failing` is
16801681
`0` — the same statement the exit code makes).
16811682
1683+
#### `os verify`
1684+
1685+
The done-bar: `os verify` green means the author-time rules pass **and** the
1686+
app behaves at runtime. It runs two stages, in order, and the second starts
1687+
only when the first passes.
1688+
1689+
```bash
1690+
os verify # Author-time rules, then CRUD round-trip fidelity
1691+
os verify --app ./objectstack.config.ts --rls # Also prove the cross-owner RLS invariant
1692+
os verify --rls --multi-tenant --json # Org-scoped boot, structured report
1693+
```
1694+
1695+
1. **Author-time rules.** The rule registry `os validate` runs, over the stack
1696+
prepared the way `os validate` prepares it: normalized, inline handlers
1697+
lowered, parsed against the protocol schema, and judged whole and then once
1698+
per package of a multi-package artifact. A stack that does not parse, or
1699+
that any gating rule refuses, fails the run here with those findings — the
1700+
same ones `os validate` reports — and the app is never booted. Advisories
1701+
(`warning` and `info` findings) never fail `os verify`; the text face counts
1702+
them, and `os validate` prints them. This stage is the rule registry, not all
1703+
of `os validate`: the package-docs lint, the capability-provider check, the
1704+
picklist-reference and view-container-name checks and `--strict` stay
1705+
`os validate`'s, so run it too.
1706+
2. **Runtime.** Boot the app in-process and exercise it through the real HTTP
1707+
stack: for each object, a record derived from its fields is created, read
1708+
back and compared (CRUD round-trip fidelity), and a create, read or
1709+
fidelity failure fails the run. With `--rls`, a second fresh boot proves the
1710+
cross-owner invariant — a member must not write what it cannot read — for a
1711+
probe persona and for one persona per declared position, and a hole fails
1712+
the run. `--multi-tenant` boots org-scoped so tenant-isolation RLS policies
1713+
apply; a walled tenancy posture (`OS_TENANCY_POSTURE` set to `isolated` or
1714+
`group`) asks for the same boot.
1715+
1716+
The config is the one `--app` names, or the auto-detected one — see
1717+
[Config File Auto-Detection](#config-file-auto-detection).
1718+
1719+
**Exit status.**
1720+
1721+
| Exit | Meaning |
1722+
|---|---|
1723+
| `0` | Both stages passed: no gating author-time finding, and no runtime failure |
1724+
| `1` | The author-time stage refused the stack (the runtime stage did not run), the runtime stage found failures, or the command could not run at all (no config found, a config that does not load, a boot failure) |
1725+
1726+
**`--json`.** What the document carries depends on where the run ended:
1727+
1728+
- **Refused by the author-time stage:** `{ "error": "<sentence>", "errors": [...] }`,
1729+
where `errors` carries the findings in the shape `os validate --json` carries
1730+
them under `errors` — the gating findings (`severity`, `rule`, `where`,
1731+
`path`, `message`, `hint`, plus `package` for a finding raised inside one
1732+
package of the artifact), or the schema issues when the stack does not parse.
1733+
- **Reached the runtime stage:** the report — `app`, `config`, `multiTenant`,
1734+
`crud`, `rls` (with `--rls`) and `hardFailures`, the count the exit status is
1735+
read from.
1736+
- **Could not run:** `{ "error": "<sentence>" }`, plus `code` and `httpStatus`
1737+
when the failure carries them.
1738+
16821739
#### `os test`
16831740
16841741
Runs Quality Protocol test scenarios (JSON-based BDD) against a running ObjectStack server.

‎packages/cli/src/commands/verify.ts‎

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { Command, Flags } from '@oclif/core';
4+
import { dirname } from 'node:path';
45
import chalk from 'chalk';
56
import { resolveTenancyPosture } from '@objectstack/types';
67
import { postureEnforcesWall } from '@objectstack/spec/security';
@@ -19,9 +20,16 @@ import {
1920
type RlsProbeDescriptor,
2021
type RlsPositionPersonaInput,
2122
} from '@objectstack/verify';
23+
import { authoringRulesFor } from '@objectstack/lint';
2224
import { loadConfig } from '../utils/config.js';
25+
import { judgeAuthorTimeRules, VERIFY_RULE_COMMAND } from '../utils/author-time-rules.js';
2326
import {
2427
printError,
28+
printStep,
29+
printSuccess,
30+
printAuthoringRuleErrors,
31+
formatZodErrors,
32+
JSON_FULL_LIST_REMEDY,
2533
emitJson,
2634
isExitSignal,
2735
errorCodeFields,
@@ -60,16 +68,22 @@ export function resolveVerifyMultiTenant(flags: { 'multi-tenant'?: boolean }): b
6068
}
6169

6270
/**
63-
* `objectstack verify` — boot the app in-process and exercise it through the
64-
* real HTTP stack, asserting runtime behavior the static gates can't see:
65-
* - data fidelity: author → write → read → assert, per object/field type
66-
* - authorization (--rls): "you can't write what you can't read" (#1994 class)
71+
* `objectstack verify` — two stages, in order:
6772
*
68-
* Exits non-zero on real failures so it drops straight into CI.
73+
* 1. the author-time rules — the registry `os validate` runs, over the stack
74+
* prepared the way `os validate` prepares it (`utils/author-time-rules.ts`).
75+
* A gating finding fails the run with those findings, and stage 2 never
76+
* starts (#21323);
77+
* 2. boot the app in-process and exercise it through the real HTTP stack,
78+
* asserting runtime behavior the static gates can't see:
79+
* - data fidelity: author → write → read → assert, per object/field type
80+
* - authorization (--rls): "you can't write what you can't read" (#1994 class)
81+
*
82+
* Exits non-zero on either stage's failures so it drops straight into CI.
6983
*/
7084
export default class Verify extends Command {
7185
static override description =
72-
'Boot the app in-process and verify it through the real HTTP stack (CRUD round-trip fidelity + the cross-owner RLS invariant)';
86+
'Run the author-time rules os validate runs, then boot the app in-process and verify it through the real HTTP stack (CRUD round-trip fidelity + the cross-owner RLS invariant)';
7387

7488
static override examples = [
7589
'<%= config.bin %> verify',
@@ -148,6 +162,10 @@ export default class Verify extends Command {
148162
}): Promise<void> {
149163
const { config, absolutePath } = await loadConfig(flags.app);
150164

165+
// Stage 1 — the author-time rules, before anything boots. A failure here
166+
// exits inside the call; see `runAuthorTimeStage`.
167+
await this.runAuthorTimeStage(config as Record<string, unknown>, absolutePath, flags.json);
168+
151169
const multiTenant = resolveVerifyMultiTenant(flags);
152170

153171
// Data fidelity runs on its own pristine stack.
@@ -263,4 +281,77 @@ export default class Verify extends Command {
263281
// stop(), so a bare return would hang. exit() also encodes the CI contract.
264282
this.exit(hardFailures > 0 ? 1 : 0);
265283
}
284+
285+
/**
286+
* Stage 1 — the author-time rule registry `os validate` runs, over the stack
287+
* prepared the way `os validate` prepares it (#21323).
288+
*
289+
* `os verify` used to start at the boot. A stack `os validate`, `os build`
290+
* and `os lint` all refuse — a lookup to an object that does not exist, an
291+
* action `visible` naming a field without `record.`, a list column naming no
292+
* field — booted, round-tripped its records and printed `✓ verify passed`:
293+
* none of the three is a runtime FAILURE, each is metadata the runtime
294+
* silently does nothing with. That made the documented done-bar a false
295+
* green. So the rules run first, and a gating finding stops the run before
296+
* the runtime stage starts — "verify is green" now implies the rule stage of
297+
* `os validate` is green.
298+
*
299+
* Exits 1 on a refusal (`this.exit` throws, so the call never returns then).
300+
* The `--json` face keeps this command's failure envelope — `error`, the
301+
* sentence — and adds `errors`, carrying the findings in the shape
302+
* `os validate --json` carries them under the same key: rule findings on a
303+
* rule refusal (each per-package one with its `package`), Zod issues on a
304+
* schema refusal. Advisories never fail the stage; the text face counts them
305+
* and points at `os validate`, which prints them.
306+
*/
307+
private async runAuthorTimeStage(
308+
config: Record<string, unknown>,
309+
absolutePath: string,
310+
json: boolean,
311+
): Promise<void> {
312+
const ruleCount = authoringRulesFor(VERIFY_RULE_COMMAND).length;
313+
if (!json) printStep(`Running author-time rules (${ruleCount})...`);
314+
const { refusal, advisories } = judgeAuthorTimeRules(VERIFY_RULE_COMMAND, config, dirname(absolutePath));
315+
316+
if (refusal === null) {
317+
if (!json) {
318+
printSuccess(`Author-time rules passed (${ruleCount} rules)`);
319+
if (advisories.length > 0) {
320+
this.log(chalk.dim(` ${advisories.length} advisory finding(s) — \`os validate\` prints them`));
321+
}
322+
this.log('');
323+
}
324+
return;
325+
}
326+
327+
const STAGE_2_SKIPPED = 'the runtime stage did not run';
328+
let sentence: string;
329+
let errors: unknown[];
330+
if (refusal.stage === 'schema') {
331+
errors = refusal.error.issues;
332+
sentence = `Validation failed (${errors.length} issue${errors.length > 1 ? 's' : ''}) — ${STAGE_2_SKIPPED}`;
333+
} else {
334+
errors = refusal.errors;
335+
const n = `${errors.length} issue${errors.length > 1 ? 's' : ''}`;
336+
sentence =
337+
refusal.stage === 'rules'
338+
? `Author-time rules failed (${n}) — ${STAGE_2_SKIPPED}`
339+
: `Author-time rules failed inside the artifact's packages (${n}) — ${STAGE_2_SKIPPED}`;
340+
}
341+
342+
if (json) {
343+
await emitJson({ error: sentence, errors }, 0, { compact: true });
344+
this.exit(1);
345+
}
346+
this.log('');
347+
printError(sentence);
348+
if (refusal.stage === 'schema') {
349+
formatZodErrors(refusal.error);
350+
} else {
351+
// `--json` on this same exit publishes every one of them as `errors`, so
352+
// the pointer resolves to the complete list.
353+
printAuthoringRuleErrors(refusal.errors, { remedy: JSON_FULL_LIST_REMEDY });
354+
}
355+
this.exit(1);
356+
}
266357
}

0 commit comments

Comments
 (0)