Skip to content

Commit 273e529

Browse files
committed
fix(cli): a retired requires token reads as its retirement prescription at the os validate / os build door
renderCapabilityMessage's unknown branch now consults RETIRED_PLATFORM_CAPABILITY_GUIDANCE, so a plain-object config declaring requires: ['reports'] gets the same prescription defineStack refuses it with and os serve warns with, instead of "check for a typo". Posture unchanged: an unknown token stays a non-fatal advisory at that door. The published platform skill's CAPABILITY_PROVIDERS table drops the retired token and states its real count (19 of 20; package-registry is always-on). Three stale comments follow the retirement: the disjointness pin's file, the setInterval-owning Plugin count (measured: one), and the retired sys_saved_report / sys_report_schedule format paths. Claude-Session: https://claude.ai/code/session_013RWUA7bNq5bRhehLPqXwMg Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4ee2885 commit 273e529

9 files changed

Lines changed: 178 additions & 12 deletions

File tree

‎.changeset/20102-retire-saved-report-stack.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ FROM → TO, per surface:
2626
- `requires: ['reports']` → **refused** by `defineStack` (`STACK_CAPABILITY_UNKNOWN`,
2727
422) with the prescription "requires: 'reports' was removed in @objectstack/spec
2828
17.5.0 … Delete the token." Fix: delete the token. `os serve` on an older artifact
29-
that still carries it warns with the same prescription and ignores it. The token is
29+
that still carries it warns with the same prescription and ignores it; `os validate`
30+
and `os build` over a plain-object config (no `defineStack` call, so no parse-time
31+
vocabulary check) report it as a non-fatal capability advisory carrying the same
32+
prescription, never "check for a typo". The token is
3033
gone from `PLATFORM_CAPABILITY_TOKENS` and `PLATFORM_CAPABILITY_PROVIDERS`; the new
3134
`RETIRED_PLATFORM_CAPABILITY_GUIDANCE` (`@objectstack/spec/kernel`) carries the
3235
prescription.

‎packages/cli/src/utils/capability-preflight.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
44
import path from 'node:path';
55
import {
66
classifyRequiredCapability,
7+
RETIRED_PLATFORM_CAPABILITY_GUIDANCE,
78
type CapabilityClassification,
89
} from '@objectstack/spec/kernel';
910

@@ -93,6 +94,12 @@ export function renderCapabilityMessage(c: CapabilityClassification): string {
9394
);
9495
}
9596
case 'unknown':
97+
// A RETIRED token is not a typo — the word used to be right — so it gets
98+
// its retirement prescription, the same text `defineStack` refuses it
99+
// with and `os serve` warns with, never "check for a typo".
100+
if (Object.prototype.hasOwnProperty.call(RETIRED_PLATFORM_CAPABILITY_GUIDANCE, c.token)) {
101+
return RETIRED_PLATFORM_CAPABILITY_GUIDANCE[c.token];
102+
}
96103
return `requires: "${c.token}" is not a known platform capability — check for a typo.`;
97104
case 'ok':
98105
default:
@@ -110,7 +117,8 @@ export interface CapabilityPreflightResult {
110117
readonly errors: CapabilityClassification[];
111118
/**
112119
* Advisory findings — `installable` (absent but addable → `pnpm add` hint) and
113-
* `unknown` (a typo). Never fatal.
120+
* `unknown` (a typo, or a retired token, which renders its retirement
121+
* prescription). Never fatal.
114122
*/
115123
readonly warnings: CapabilityClassification[];
116124
}

‎packages/cli/test/capability-preflight.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import {
99
classifyRequiredCapability,
1010
PLATFORM_CAPABILITY_PROVIDERS,
11+
RETIRED_PLATFORM_CAPABILITY_GUIDANCE,
1112
} from '@objectstack/spec/kernel';
1213

1314
// framework#3366 — the CLI-side resolution + message layer over the spec-owned
@@ -41,6 +42,12 @@ describe('preflightRequiredCapabilities (#3366)', () => {
4142
expect(r.warnings.map((c) => c.status)).toEqual(['unknown']);
4243
});
4344

45+
it('a retired token keeps the unknown-token posture: advisory, never fatal', () => {
46+
const r = call(['reports'], () => true);
47+
expect(r.errors).toHaveLength(0);
48+
expect(r.warnings.map((c) => [c.token, c.status])).toEqual([['reports', 'unknown']]);
49+
});
50+
4451
it('dedupes and ignores non-string entries', () => {
4552
const r = call(['ai', 'ai', 42, null, 'ai'], () => false);
4653
expect(r.errors).toHaveLength(1);
@@ -91,6 +98,24 @@ describe('renderCapabilityMessage (#3366)', () => {
9198
it('unknown token reads as a typo hint', () => {
9299
expect(msgFor('automations', () => true)).toContain('not a known platform capability');
93100
});
101+
102+
it('a RETIRED token renders its retirement prescription, never the typo hint', () => {
103+
// `os validate` / `os build` parse a plain-object config without the
104+
// `defineStack` vocabulary check, so this renderer is the only text an
105+
// author at those doors sees. It must be the spec-owned prescription
106+
// verbatim — the same words `defineStack` refuses the token with.
107+
const c = classifyRequiredCapability('reports', () => true);
108+
expect(c.status).toBe('unknown');
109+
const prescription = RETIRED_PLATFORM_CAPABILITY_GUIDANCE.reports;
110+
expect(prescription, 'the retirement map must carry a `reports` row').toBeTruthy();
111+
const m = renderCapabilityMessage(c);
112+
expect(m).toBe(prescription);
113+
expect(m).not.toContain('check for a typo');
114+
// Control: an ordinary misspelling still gets the typo hint, and the
115+
// lookup is an own-property read (an inherited name is not a retired token).
116+
expect(msgFor('reportz', () => true)).toContain('check for a typo');
117+
expect(msgFor('constructor', () => true)).toContain('check for a typo');
118+
});
94119
});
95120

96121
describe('missingProviderMessage — serve reads identically to the preflight (#3366)', () => {
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A RETIRED `requires` token reads as its retirement prescription at the
5+
* `os validate` / `os build` door — never as "check for a typo".
6+
*
7+
* Both commands accept a PLAIN-OBJECT config (`export default { … }`, no
8+
* `defineStack` call). That config is parsed with
9+
* `ObjectStackDefinitionSchema.safeParse`, whose `requires` is a bare string
10+
* array — the `defineStack` vocabulary check never runs — and the only text
11+
* the author sees for a `requires` token is the capability preflight's
12+
* `renderCapabilityMessage`. For a word that USED to be a capability (the
13+
* saved-report stack's `reports`), the typo advice is wrong in a way that
14+
* sends the author hunting for a spelling instead of deleting the token.
15+
*
16+
* Posture is unchanged: an unknown token is an ADVISORY at this door (exit 0,
17+
* a `{ token, message }` record in `warnings`); only its TEXT is asserted
18+
* here, and it is asserted EQUAL to the spec-owned prescription — the same
19+
* string `defineStack` refuses the token with and `os serve` warns with.
20+
*
21+
* A misspelled token rides in the same fixture as the control, so "the
22+
* prescription is shown" cannot pass against a renderer that shows it for
23+
* every unknown token.
24+
*/
25+
26+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
27+
import { execFile } from 'node:child_process';
28+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
29+
import { tmpdir } from 'node:os';
30+
import { join, resolve } from 'node:path';
31+
import { fileURLToPath } from 'node:url';
32+
import { RETIRED_PLATFORM_CAPABILITY_GUIDANCE } from '@objectstack/spec/kernel';
33+
import { childEnv } from './helpers/serve-process.js';
34+
35+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
36+
const CLI = resolve(HERE, '../bin/run-dev.js');
37+
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
38+
39+
interface Run {
40+
code: number;
41+
stdout: string;
42+
stderr: string;
43+
}
44+
45+
function runCli(args: string[], cwd: string): Promise<Run> {
46+
return new Promise((resolvePromise) => {
47+
execFile(
48+
TSX,
49+
[CLI, ...args],
50+
{ cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
51+
(err, stdout, stderr) => {
52+
resolvePromise({
53+
code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0,
54+
stdout: String(stdout),
55+
stderr: String(stderr),
56+
});
57+
},
58+
);
59+
});
60+
}
61+
62+
function capabilityHints(run: Run, label: string): Map<string, string> {
63+
let payload: { warnings?: unknown };
64+
try {
65+
payload = JSON.parse(run.stdout) as { warnings?: unknown };
66+
} catch {
67+
throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`);
68+
}
69+
const hints = new Map<string, string>();
70+
for (const w of Array.isArray(payload.warnings) ? payload.warnings : []) {
71+
if (typeof w === 'object' && w !== null && 'token' in w) {
72+
const r = w as { token: unknown; message: unknown };
73+
hints.set(String(r.token), String(r.message));
74+
}
75+
}
76+
return hints;
77+
}
78+
79+
const RETIRED_TOKEN = 'reports';
80+
const TYPO_TOKEN = 'reportz';
81+
82+
/** A plain-object config: no `defineStack`, so no parse-time vocabulary check. */
83+
const CONFIG = `
84+
export default {
85+
manifest: { id: 'com.example.retiredcap', name: 'retiredcap', version: '1.0.0', type: 'app', namespace: 'retiredcap' },
86+
requires: ['${RETIRED_TOKEN}', '${TYPO_TOKEN}'],
87+
objects: [
88+
{
89+
name: 'rc_thing',
90+
label: 'Thing',
91+
sharingModel: 'private',
92+
fields: { title: { type: 'text', label: 'Title' } },
93+
},
94+
],
95+
};
96+
`;
97+
98+
let dir = '';
99+
100+
beforeAll(() => {
101+
dir = mkdtempSync(join(tmpdir(), 'os-retired-cap-'));
102+
writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG);
103+
});
104+
105+
afterAll(() => {
106+
if (dir) rmSync(dir, { recursive: true, force: true });
107+
});
108+
109+
describe('a retired `requires` token at the `os validate` / `os build` door (plain-object config)', () => {
110+
const prescription = RETIRED_PLATFORM_CAPABILITY_GUIDANCE[RETIRED_TOKEN];
111+
112+
it('the spec carries a prescription for the token under test', () => {
113+
expect(prescription, 'RETIRED_PLATFORM_CAPABILITY_GUIDANCE lost its `reports` row').toBeTruthy();
114+
});
115+
116+
for (const command of ['validate', 'build'] as const) {
117+
it(`os ${command} --json: advisory posture, the retired token carries the prescription, the typo keeps the typo hint`, async () => {
118+
const run = await runCli([command, '--json'], dir);
119+
expect(run.code, `os ${command} --json failed:\n${run.stdout}${run.stderr}`).toBe(0);
120+
const hints = capabilityHints(run, `os ${command} --json`);
121+
expect([...hints.keys()].sort()).toEqual([RETIRED_TOKEN, TYPO_TOKEN].sort());
122+
expect(hints.get(RETIRED_TOKEN)).toBe(prescription);
123+
expect(hints.get(RETIRED_TOKEN)).not.toContain('check for a typo');
124+
expect(hints.get(TYPO_TOKEN)).toContain('check for a typo');
125+
}, 180_000);
126+
}
127+
});

‎packages/platform-objects/src/apps/translations/object-field-editor-panel-echo-decisions.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ const DECISIONS: readonly Decision[] = [
301301
en: 'Autonumber Format',
302302
verdict: ALL_TRANSLATE,
303303
reason:
304-
'A compound of two ordinary words: Format is authored as 格式 / フォーマット / Formato at three other paths (dataset.fields.measures.format, sys_saved_report.format, sys_report_schedule.format), and this panel authors its other format-shaped label, maskingRule, rather than keeping it English.',
304+
'A compound of two ordinary words: Format is authored as 格式 / フォーマット / Formato at dataset.fields.measures.format (sys_saved_report.format and sys_report_schedule.format carried it too until the saved-report stack was retired), and this panel authors its other format-shaped label, maskingRule, rather than keeping it English.',
305305
},
306306
{
307307
type: 'object',

‎packages/runtime/src/external-validation-plugin.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,9 +236,11 @@ export class ExternalValidationPlugin implements Plugin {
236236
* and this class's own only caller was `scheduleDriftChecks()` re-arming
237237
* itself — so every armed `setInterval` below was STILL ARMED after
238238
* `await kernel.shutdown()` had RESOLVED. That is #9371's mechanism
239-
* verbatim, and this plugin is one of only two `Plugin` implementations in
240-
* the tree that own `setInterval` at all (`ReportsServicePlugin` is the
241-
* other, repaired under #10371).
239+
* verbatim. This plugin is the only `Plugin` implementation in the tree that
240+
* calls `setInterval` itself (measured over non-test sources: 14 files call
241+
* it, and every other enclosing class is a service, driver, adapter or
242+
* helper); `ReportsServicePlugin`, the other one, was repaired under #10371
243+
* and later retired whole with the saved-report stack.
242244
*
243245
* The timers are `unref`'d, so a long-lived host process still exits and
244246
* nothing complains in production — the bill lands in a vitest worker, which

‎packages/runtime/src/external-validation-shutdown-clears-timers.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@
1414
* plugins in reverse calling `plugin.destroy()`, walked straight past it. The
1515
* only caller `stop()` had anywhere in the tree was this class's own
1616
* `scheduleDriftChecks()` re-arming itself. So on kernel shutdown the
17-
* intervals were NEVER cleared — the #9371 mechanism verbatim, in one of only
18-
* two `Plugin` implementations in this tree that own `setInterval` at all
19-
* (`ReportsServicePlugin` is the other, repaired under #10371).
17+
* intervals were NEVER cleared — the #9371 mechanism verbatim, in the only
18+
* `Plugin` implementation in this tree that calls `setInterval` itself
19+
* (`ReportsServicePlugin` was the other, repaired under #10371 and since
20+
* retired with the saved-report stack).
2021
*
2122
* This plugin is mounted on the real serve path
2223
* (`packages/cli/src/commands/serve.ts` — `kernel.use(createExternalValidationPlugin())`),

‎packages/spec/src/kernel/platform-capabilities.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export const PLATFORM_CAPABILITY_TOKENS: readonly string[] = Object.freeze([
8282
* answer for a word that used to be right, and it leaves the author (very often
8383
* a model repeating an older example) with no idea what replaced it. A row here
8484
* turns that refusal into the retirement notice. Keys are DISJOINT from
85-
* {@link PLATFORM_CAPABILITY_TOKENS} — pinned in `platform-capabilities.test.ts`
85+
* {@link PLATFORM_CAPABILITY_TOKENS} — pinned in `reports-capability-retirement.test.ts`
8686
* — so a token can never be both declared and retired.
8787
*
8888
* Declaring a retired token is refused, never tolerated: there is no provider

‎skills/objectstack-platform/SKILL.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,14 +359,14 @@ mode-dependent — dev hops to the next free port, production fails loudly. See
359359
Step 4's list is the fixed core. Every other service plugin is opt-in, and
360360
`requires: [...]` on the stack root is what turns it on. The CLI expands each
361361
token through the `CAPABILITY_PROVIDERS` registry in
362-
`packages/cli/src/commands/serve.ts` — all 20 of its entries:
362+
`packages/cli/src/commands/serve.ts` — 19 of its 20 entries (`package-registry` is always-on):
363363

364364
| Token | Provider package |
365365
|:--|:--|
366366
| `automation` | `@objectstack/service-automation` — flows, and any declarative `connectors:` entry |
367367
| `analytics` `cache` `storage` `queue` `job` `messaging` `realtime` `settings` `sms` | `@objectstack/service-` + the token |
368368
| `marketplace` | `@objectstack/service-package` |
369-
| `audit` `email` `sharing` `reports` `approvals` `webhooks` | `@objectstack/plugin-` + the token |
369+
| `audit` `email` `sharing` `approvals` `webhooks` | `@objectstack/plugin-` + the token |
370370
| `pinyin-search` | `@objectstack/plugin-pinyin-search` |
371371
| `mcp` | `@objectstack/mcp` |
372372
| `triggers` | `@objectstack/trigger-record-change`, plus `trigger-schedule` and `trigger-api`. **Pair it with `job`** — schedule and time-relative triggers run on the job service |

0 commit comments

Comments
 (0)