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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .changeset/17781-runtime-config-resource-limits-timeout-ms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
"@objectstack/spec": minor
---

feat(spec)!: the fifth `kernel/plugin-security-advanced.zod.ts` duration — `RuntimeConfig.resourceLimits.timeout` — carries its unit in the key name (#17781, ruling A on #15939)

<!-- adr-0087: registered kernel-runtime-config-timeout-unit-in-key -->

**BREAKING** — the execution timeout on a plugin sandbox's runtime block carries its unit in the
key name.

| | before | after |
|:--|:--|:--|
| authored key | `resourceLimits.timeout: 60000` | `resourceLimits.timeoutMs: 60000` |
| published describe | `Maximum execution time` | `Maximum execution time in milliseconds` |
| value + bound | milliseconds, `int().min(0)` | **unchanged** |

## Migration

```diff
resourceLimits: {
maxMemory: 1073741824,
- timeout: 60000,
+ timeoutMs: 60000,
}
```

Rename the key. The value is the same number of milliseconds it always was and the `int().min(0)`
bound rides along with it; nothing else on `RuntimeConfig` moves.

## Why

This is the key #15678 deliberately left alone, and this changeset closes it. `#15678` renamed the
four other plugin-security durations on this same file and recorded, accurately, that this one was
out of its scope: `resourceLimits.timeout` named its unit only in the JSDoc above it — "Execution
timeout in milliseconds" — a channel `check:duration-unit-keys` does not read (it reads
`.describe()` and `.meta({ description })`), and its describe said "Maximum execution time" and
named no unit at all. So the gate listed the key among the duration-shaped keys without judging it,
neither an offender nor an exemption, and the reader who most needs the unit — the reader of
`content/docs/references/kernel/plugin-security-advanced.mdx`, who never sees the source JSDoc —
got a bare integer and could not tell 60000 milliseconds from 60000 seconds. That JSDoc-channel gap
was filed as #15939 and is now ruled: director-seat **ruling A** (2026-09-11, maintainer 「同意」,
decision batch #115) remediates the population per file. Under the #14478 rule, moving the unit
into the describe alone is itself a violation — unit in prose, none in the name — so the key is
renamed and the describe is corrected in one stroke.

Spelled `Ms`, the same token `SandboxConfig.process.timeoutMs` on this very file already carries:
counted on this tree, the suffixed family spells it that way in every member (29 key-position
`timeoutMs` declarations across `packages/spec/src/**/*.zod.ts`, 40 distinct `*Ms` keys), and no
`timeoutMillis`, `timeout_ms` or `timeoutMS` variant exists anywhere in `packages/spec/src`.

⚠️ Two keys on this one file spelled `timeout` and both now retire to a key spelled `timeoutMs`:
`RuntimeConfig.resourceLimits.timeout` (this one) and `SandboxConfig.process.timeout` (#15678).
They are different keys on different shapes, so each refusal names its own shape — check which
block you are editing.

## The kit

- a `retiredKey()` tombstone on the old spelling, so `tsc` types it `never` and a value reaching
the parse raises the rename prescription instead of being silently stripped (the nested
`resourceLimits` object is not `.strict()`)
- the ADR-0087 D3 semantic entry `kernel-runtime-config-timeout-unit-in-key`, which states
explicitly that it completes what #15678 left alone so the two read as a sequence, and the
`RETIRED_KEYS_BY_MAJOR[18]` row `kernel/RuntimeConfig:resourceLimits.timeout`. No D2 conversion:
a `RuntimeConfig` is the engine block of the `SandboxConfig` a host or a plugin security manifest
constructs, `stack.zod.ts` declares no sandbox, security-policy or runtime-config collection, and
it is not a stored `sys_metadata` row — so the chain has no seam that runs on it. That is the
same reading #15678 recorded for the four keys it renamed.
- the pin test that asserted this key stays bare is **replaced, not removed**: it now pins that the
bare spelling is refused with the rename prescription, that `timeoutMs` parses at the same
magnitude beside its siblings, that the describe publishes the unit, and that the two same-named
`timeout` retirements on this file name their own shapes apart
- `content/docs/references/kernel/plugin-security-advanced.mdx` regenerated by `gen:docs`: three
rows move and the tombstone prescription renders in place of the old describe
- no authorable-surface row moves — that ratchet records top-level keys per def, and this key is
nested under `resourceLimits` (measured: `kernel/RuntimeConfig:` carries exactly
`engine`, `engineConfig` and `resourceLimits` across `authorable-surface/` and
`authorable-surface.base.json`, and `check:authorable-surface` is green without regeneration)
7 changes: 4 additions & 3 deletions content/docs/references/kernel/plugin-security-advanced.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -359,15 +359,16 @@ Type of resource being accessed
| :--- | :--- | :--- | :--- |
| **engine** | `Enum<'v8-isolate' \| 'wasm' \| 'container' \| 'process'>` | optional (default: `"v8-isolate"`) | Execution environment engine |
| **engineConfig** | `{ wasm?: object; container?: object; v8Isolate?: object }` | optional | |
| **resourceLimits** | `{ maxMemory?: integer; maxCpu?: number; timeout?: integer }` | optional | |
| **resourceLimits** | `{ maxMemory?: integer; maxCpu?: number; timeoutMs?: integer }` | optional | |

### Nested Shape: `RuntimeConfig.resourceLimits`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **maxMemory** | `integer` | optional | Maximum memory allocation |
| **maxCpu** | `number` | optional | Maximum CPU usage percentage |
| **timeout** | `integer` | optional | Maximum execution time |
| **timeoutMs** | `integer` | optional | Maximum execution time in milliseconds |
| **timeout** | `never` | optional | [REMOVED] `RuntimeConfig.resourceLimits.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Its unit (milliseconds) lived in a source JSDoc only and the published description read "Maximum execution time", naming no unit at all, so a reader of the reference page could not tell 60000 milliseconds from 60000 seconds. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. |


---
Expand All @@ -394,7 +395,7 @@ Type of resource being accessed
| :--- | :--- | :--- | :--- |
| **engine** | `Enum<'v8-isolate' \| 'wasm' \| 'container' \| 'process'>` | optional (default: `"v8-isolate"`) | Execution environment engine |
| **engineConfig** | `{ wasm?: object; container?: object; v8Isolate?: object }` | optional | |
| **resourceLimits** | `{ maxMemory?: integer; maxCpu?: number; timeout?: integer }` | optional | |
| **resourceLimits** | `{ maxMemory?: integer; maxCpu?: number; timeoutMs?: integer }` | optional | |

### Nested Shape: `SandboxConfig.filesystem`

Expand Down
79 changes: 69 additions & 10 deletions packages/spec/src/kernel/plugin-security-advanced.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe('Plugin Security Advanced Schemas', () => {
resourceLimits: {
maxMemory: 16777216, // 16MB
maxCpu: 50,
timeout: 30000,
timeoutMs: 30000,
},
};
const result = RuntimeConfigSchema.parse(config);
Expand Down Expand Up @@ -67,7 +67,7 @@ describe('Plugin Security Advanced Schemas', () => {
resourceLimits: {
maxMemory: 1073741824, // 1GB
maxCpu: 100,
timeout: 60000,
timeoutMs: 60000,
},
};
const result = RuntimeConfigSchema.parse(config);
Expand Down Expand Up @@ -392,17 +392,76 @@ describe('Plugin security durations carry their unit (#15678)', () => {
expect(parsed.vulnerabilityDisclosure?.responseTimeHours).toBe(24);
expect(parsed.vulnerabilityDisclosure?.bugBounty).toBe(true);
});
});

// A NEGATIVE control on the same file: this key names its unit only in the
// JSDoc above it, a channel the gate does not read (it reads `.describe()` and
// `.meta({ description })`) — so the gate lists it without judging it, and it
// is outside this rename. The JSDoc-channel gap is #15939. Without this test, a
// later sweep reads the four renames above as "every timeout on this file".
it('leaves `RuntimeConfig.resourceLimits.timeout` bare — its describe names no unit', () => {
const parsed = RuntimeConfigSchema.parse({
// THE FIFTH DURATION, closed by #15939 ruling A — what this slot used to pin.
//
// Until this card, this slot held a NEGATIVE control: `resourceLimits.timeout`
// named its unit only in the JSDoc above it, a channel `check:duration-unit-keys`
// does not read (it reads `.describe()` and `.meta({ description })`), so the
// gate listed the key in its census without judging it and #15678 deliberately
// left it alone. The control existed so a later sweep could not read the four
// renames above as "every timeout on this file".
//
// #15939 IS that sweep, and it is the reason the control was written. Director
// seat ruling A (2026-09-11, maintainer 「同意」, decision batch #115) remediates
// the JSDoc-channel population per file, so the key is renamed here and this
// slot now pins the OPPOSITE fact: the bare spelling is refused with the rename
// prescription, and the suffixed spelling parses at the same magnitude. The
// guard succeeded by failing — ⛔ it was not deleted, weakened or skipped.
describe('RuntimeConfig.resourceLimits.timeout → timeoutMs (#15939 ruling A, #14478)', () => {
it('REFUSES the retired `resourceLimits.timeout` with the rename to `timeoutMs`', () => {
const result = RuntimeConfigSchema.safeParse({
engine: 'process' as const,
resourceLimits: { maxMemory: 1073741824, timeout: 60000 },
});
expect(parsed.resourceLimits?.timeout).toBe(60000);
expect(result.success).toBe(false);
const issue = result.error!.issues.find(
(i) => i.path.join('.') === 'resourceLimits.timeout',
);
expect(issue).toBeDefined();
expect(issue!.code).not.toBe('unrecognized_keys');
expect(issue!.message).toMatch(
/`RuntimeConfig\.resourceLimits\.timeout` was renamed.*Rename the key to `timeoutMs`/s,
);
expect(issue!.message).toContain('the value (milliseconds) is unchanged');
});

it('accepts `timeoutMs` at the magnitude the retired key carried, beside its siblings', () => {
const parsed = RuntimeConfigSchema.parse({
engine: 'process' as const,
resourceLimits: { maxMemory: 1073741824, maxCpu: 100, timeoutMs: 60000 },
});
expect(parsed.resourceLimits?.timeoutMs).toBe(60000);
expect(parsed.resourceLimits).not.toHaveProperty('timeout');
expect(parsed.resourceLimits?.maxMemory).toBe(1073741824);
expect(parsed.resourceLimits?.maxCpu).toBe(100);
});

// The channel the whole of #15939 is about: the describe is what
// `content/docs/references/**` renders, and it now names the unit.
it('publishes the unit in the describe — the text the reference pages render', () => {
const limits = RuntimeConfigSchema.shape.resourceLimits.unwrap();
expect(limits.shape.timeoutMs.description).toBe('Maximum execution time in milliseconds');
});

// The two `timeout` keys on this file are DIFFERENT keys on different
// shapes, and both are now retired to the same `timeoutMs` token. Each
// refusal must name its own shape, or an upgrading author edits the wrong
// block — the confusion `SandboxConfig.process.timeout`'s own tombstone
// comment warned about while this key was still bare.
it('names its own shape, not the sandbox one, in the prescription', () => {
const runtime = RuntimeConfigSchema.safeParse({
resourceLimits: { timeout: 60000 },
});
const sandbox = SandboxConfigSchema.safeParse({ process: { timeout: 30000 } });
const runtimeMsg = runtime.error!.issues
.find((i) => i.path.join('.') === 'resourceLimits.timeout')!.message;
const sandboxMsg = sandbox.error!.issues
.find((i) => i.path.join('.') === 'process.timeout')!.message;
expect(runtimeMsg).toContain('`RuntimeConfig.resourceLimits.timeout`');
expect(runtimeMsg).not.toContain('`SandboxConfig.process.timeout`');
expect(sandboxMsg).toContain('`SandboxConfig.process.timeout`');
expect(sandboxMsg).not.toContain('`RuntimeConfig.resourceLimits.timeout`');
});
});
31 changes: 28 additions & 3 deletions packages/spec/src/kernel/plugin-security-advanced.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,20 @@ export const PluginPermissionSetSchema = lazySchema(() => z.object({
* Runtime Configuration
* Defines the execution environment for plugin isolation
*/
// Declared ABOVE its consumer on purpose: `gen:schema` and
// `check:authorable-surface` run with `OS_EAGER_SCHEMAS=1`, which makes
// `lazySchema` evaluate the factory at module load, so a const declared after
// `RuntimeConfigSchema` would be read from its temporal dead zone. The four
// tombstone strings further down sit after their schemas for the same reason —
// every one of them is declared before the block that reads it.
const RUNTIME_RESOURCE_LIMITS_TIMEOUT_RETIRED =
'`RuntimeConfig.resourceLimits.timeout` was renamed to `timeoutMs` in @objectstack/spec 17 — '
+ 'the unit of a duration-shaped number lives in the key name, not only in the describe prose. '
+ 'Its unit (milliseconds) lived in a source JSDoc only and the published description read '
+ '"Maximum execution time", naming no unit at all, so a reader of the reference page could '
+ 'not tell 60000 milliseconds from 60000 seconds. Rename the key to `timeoutMs`; the value '
+ '(milliseconds) is unchanged.';

export const RuntimeConfigSchema = lazySchema(() => z.object({
/**
* Runtime engine type
Expand Down Expand Up @@ -289,10 +303,21 @@ export const RuntimeConfigSchema = lazySchema(() => z.object({
.describe('Maximum CPU usage percentage'),

/**
* Execution timeout in milliseconds
* Execution timeout in milliseconds.
*
* Renamed from `timeout` (#15939 ruling A, executing #14478 ruling B): the
* unit lived in this JSDoc only, and `.describe()` — the text
* `content/docs/references/kernel/plugin-security-advanced.mdx` publishes —
* read "Maximum execution time" and named none. Spelled `Ms`, the same
* token `SandboxConfig.process.timeoutMs` on this file already carries.
* Tombstoned rather than deleted because this nested `resourceLimits`
* object is not `.strict()`.
*/
timeout: z.number().int().min(0).optional()
.describe('Maximum execution time'),
timeoutMs: z.number().int().min(0).optional()
.describe('Maximum execution time in milliseconds'),

/** Tombstone for the rename above (#15939 ruling A, executing #14478). */
timeout: retiredKey(RUNTIME_RESOURCE_LIMITS_TIMEOUT_RETIRED),
}).optional(),
}));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

// #15939 ruling A (per-file remediation of #14478 ruling B). This is the fifth
// duration on `kernel/plugin-security-advanced.zod.ts` and the one #15678
// deliberately left alone: `resourceLimits.timeout` said "Execution timeout in
// milliseconds" in a source JSDoc and "Maximum execution time" in the
// `.describe()` the reference pages publish, so the published channel named no
// unit at all and the gate listed the key in its census without judging it.
// Renamed to `timeoutMs`, the same token `SandboxConfig.process.timeoutMs` on
// this file already carries. The value is unchanged. Tombstoned with
// `retiredKey()`: the nested `resourceLimits` object is not strict, so a bare
// deletion would silently strip the key. No D2 conversion: a `RuntimeConfig` is
// the engine block of the `SandboxConfig` a host or a plugin security manifest
// constructs, never a stack collection member or a stored row — the same
// reading `kernel-plugin-security-durations-unit-in-key` recorded for the four
// keys it renamed. See `kernel-runtime-config-timeout-unit-in-key`.
export const entry = 'kernel/RuntimeConfig:resourceLimits.timeout';
Loading
Loading