@skill-map/spec 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/README.md +105 -0
- package/architecture.md +218 -0
- package/cli-contract.md +336 -0
- package/conformance/README.md +140 -0
- package/conformance/cases/basic-scan.json +17 -0
- package/conformance/cases/kernel-empty-boot.json +24 -0
- package/conformance/fixtures/minimal-claude/agents/reviewer.md +16 -0
- package/conformance/fixtures/minimal-claude/commands/status.md +17 -0
- package/conformance/fixtures/minimal-claude/hooks/pre-commit.md +13 -0
- package/conformance/fixtures/minimal-claude/notes/architecture.md +11 -0
- package/conformance/fixtures/minimal-claude/skills/hello.md +22 -0
- package/conformance/fixtures/preamble-v1.txt +54 -0
- package/db-schema.md +359 -0
- package/dispatch-lifecycle.md +213 -0
- package/index.json +205 -0
- package/interfaces/security-scanner.md +233 -0
- package/job-events.md +322 -0
- package/package.json +49 -0
- package/plugin-kv-api.md +208 -0
- package/prompt-preamble.md +152 -0
- package/schemas/conformance-case.schema.json +185 -0
- package/schemas/execution-record.schema.json +88 -0
- package/schemas/frontmatter/agent.schema.json +22 -0
- package/schemas/frontmatter/base.schema.json +136 -0
- package/schemas/frontmatter/command.schema.json +39 -0
- package/schemas/frontmatter/hook.schema.json +29 -0
- package/schemas/frontmatter/note.schema.json +11 -0
- package/schemas/frontmatter/skill.schema.json +37 -0
- package/schemas/issue.schema.json +54 -0
- package/schemas/job.schema.json +75 -0
- package/schemas/link.schema.json +66 -0
- package/schemas/node.schema.json +95 -0
- package/schemas/plugins-registry.schema.json +99 -0
- package/schemas/project-config.schema.json +87 -0
- package/schemas/report-base.schema.json +41 -0
- package/schemas/scan-result.schema.json +71 -0
- package/schemas/summaries/agent.schema.json +46 -0
- package/schemas/summaries/command.schema.json +50 -0
- package/schemas/summaries/hook.schema.json +43 -0
- package/schemas/summaries/note.schema.json +37 -0
- package/schemas/summaries/skill.schema.json +57 -0
- package/versioning.md +94 -0
package/job-events.md
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# Job events
|
|
2
|
+
|
|
3
|
+
Canonical event stream emitted during job execution. Every implementation MUST emit these events in the order described, with the shapes defined below. Consumers include the CLI pretty printer, the `--json` ndjson output, the Server's WebSocket broadcaster, and any third-party integration.
|
|
4
|
+
|
|
5
|
+
This document is **normative**. The set of event types, their payload shapes, and their ordering rules are stable contracts.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Transport
|
|
10
|
+
|
|
11
|
+
Events are records produced by the kernel through `ProgressEmitterPort` (see `architecture.md`). An implementation MUST provide three output adapters:
|
|
12
|
+
|
|
13
|
+
| Adapter | Purpose | Format |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `pretty` | Default TTY output. Human-readable, colored, line-based progress. | Free-form; not normative. |
|
|
16
|
+
| `stream-output` | Pretty + model tokens inline. Debugging mode. | Free-form; not normative. |
|
|
17
|
+
| `json` | Machine-readable ndjson. One event per line; each line is a complete JSON object. | **Normative.** Matches the shapes below. |
|
|
18
|
+
|
|
19
|
+
The Server exposes the same events over WebSocket (`/ws`) using the same JSON shapes; each event is a single WebSocket text frame.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Common envelope
|
|
24
|
+
|
|
25
|
+
Every event is a JSON object with this envelope:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"type": "<event-type>",
|
|
30
|
+
"timestamp": <unix-ms>,
|
|
31
|
+
"runId": "<run-id>",
|
|
32
|
+
"jobId": "<job-id> | null",
|
|
33
|
+
"data": { ... }
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| Field | Required | Meaning |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `type` | always | One of the canonical event types below. |
|
|
40
|
+
| `timestamp` | always | Unix milliseconds when the event was emitted. |
|
|
41
|
+
| `runId` | always | Identifier of the `sm job run` invocation. One run emits many events. Format: `r-YYYYMMDD-HHMMSS-XXXX`. |
|
|
42
|
+
| `jobId` | when job-scoped | The job the event refers to. Null for run-level events (`run.*`). |
|
|
43
|
+
| `data` | per-event | Event-specific payload, shape defined below. |
|
|
44
|
+
|
|
45
|
+
Implementations MUST include every envelope field in every event, even if `jobId` is null. This simplifies consumers.
|
|
46
|
+
|
|
47
|
+
Unknown fields in `data` MUST be ignored by consumers (forward compatibility).
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Event catalog
|
|
52
|
+
|
|
53
|
+
Emitted in roughly this order during a `sm job run --all` invocation. The exact sequence may interleave for parallel runs (post-MVP).
|
|
54
|
+
|
|
55
|
+
### `run.started`
|
|
56
|
+
|
|
57
|
+
Emitted once at the start of every `sm job run` invocation.
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"type": "run.started",
|
|
62
|
+
"timestamp": 1745159455123,
|
|
63
|
+
"runId": "r-20260420-143055-a3f2",
|
|
64
|
+
"jobId": null,
|
|
65
|
+
"data": {
|
|
66
|
+
"mode": "single | all | max",
|
|
67
|
+
"maxJobs": 10,
|
|
68
|
+
"filter": { "action": "skill-summarizer" }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- `mode`: what the runner was asked to do.
|
|
74
|
+
- `maxJobs`: cap on concurrent drain (`--max N` or null).
|
|
75
|
+
- `filter`: resolved filter predicate, free-form object.
|
|
76
|
+
|
|
77
|
+
### `run.reap.started`
|
|
78
|
+
|
|
79
|
+
Emitted before auto-reap scans for expired jobs.
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"type": "run.reap.started",
|
|
84
|
+
"timestamp": 1745159455200,
|
|
85
|
+
"runId": "...",
|
|
86
|
+
"jobId": null,
|
|
87
|
+
"data": {}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### `run.reap.completed`
|
|
92
|
+
|
|
93
|
+
Emitted after auto-reap finishes.
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"type": "run.reap.completed",
|
|
98
|
+
"timestamp": 1745159455201,
|
|
99
|
+
"runId": "...",
|
|
100
|
+
"jobId": null,
|
|
101
|
+
"data": {
|
|
102
|
+
"reapedCount": 0,
|
|
103
|
+
"reapedIds": []
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- `reapedIds` lists the jobs transitioned from `running` to `failed`. May be empty.
|
|
109
|
+
|
|
110
|
+
### `job.claimed`
|
|
111
|
+
|
|
112
|
+
Emitted when the runner successfully claims a job.
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"type": "job.claimed",
|
|
117
|
+
"timestamp": 1745159455300,
|
|
118
|
+
"runId": "...",
|
|
119
|
+
"jobId": "d-20260420-143055-b001",
|
|
120
|
+
"data": {
|
|
121
|
+
"actionId": "skill-summarizer",
|
|
122
|
+
"actionVersion": "1.2.0",
|
|
123
|
+
"nodeId": "skills/my-skill.md",
|
|
124
|
+
"ttlSeconds": 180,
|
|
125
|
+
"priority": 0
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### `job.skipped`
|
|
131
|
+
|
|
132
|
+
Emitted when a drain attempts to claim but finds no eligible job.
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"type": "job.skipped",
|
|
137
|
+
"timestamp": 1745159455400,
|
|
138
|
+
"runId": "...",
|
|
139
|
+
"jobId": null,
|
|
140
|
+
"data": {
|
|
141
|
+
"reason": "queue-empty | filter-excluded-all"
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### `job.spawning`
|
|
147
|
+
|
|
148
|
+
Emitted when the runner is about to execute the job file.
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
{
|
|
152
|
+
"type": "job.spawning",
|
|
153
|
+
"timestamp": 1745159455500,
|
|
154
|
+
"runId": "...",
|
|
155
|
+
"jobId": "...",
|
|
156
|
+
"data": {
|
|
157
|
+
"runner": "cli | skill | in-process",
|
|
158
|
+
"command": "claude -p",
|
|
159
|
+
"jobFilePath": ".skill-map/jobs/d-20260420-143055-b001.md"
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`command` is implementation-defined free-form; it is descriptive, not invokable.
|
|
165
|
+
|
|
166
|
+
### `model.delta`
|
|
167
|
+
|
|
168
|
+
Emitted in `stream-output` mode only. Carries incremental model output.
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"type": "model.delta",
|
|
173
|
+
"timestamp": 1745159456000,
|
|
174
|
+
"runId": "...",
|
|
175
|
+
"jobId": "...",
|
|
176
|
+
"data": {
|
|
177
|
+
"text": "Analyzing the skill...",
|
|
178
|
+
"channel": "assistant | thinking | tool-use"
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Consumers of the canonical `json` output MAY receive these events if the runner chose to emit them. `pretty` and `json` adapters MAY drop `model.delta` events for brevity.
|
|
184
|
+
|
|
185
|
+
### `job.callback.received`
|
|
186
|
+
|
|
187
|
+
Emitted inside `sm record` when the callback arrives and passes nonce validation.
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"type": "job.callback.received",
|
|
192
|
+
"timestamp": 1745159465000,
|
|
193
|
+
"runId": "...",
|
|
194
|
+
"jobId": "...",
|
|
195
|
+
"data": {
|
|
196
|
+
"status": "completed | failed",
|
|
197
|
+
"model": "claude-opus-4-7",
|
|
198
|
+
"reportPath": ".skill-map/reports/d-20260420-143055-b001.json"
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`runId` on this event is the run that originally claimed the job. If the record is called from outside a run (interactive skill), `runId` is a synthetic id prefixed `r-ext-`.
|
|
204
|
+
|
|
205
|
+
### `job.completed`
|
|
206
|
+
|
|
207
|
+
Emitted when a job transitions to `completed`.
|
|
208
|
+
|
|
209
|
+
```json
|
|
210
|
+
{
|
|
211
|
+
"type": "job.completed",
|
|
212
|
+
"timestamp": 1745159465100,
|
|
213
|
+
"runId": "...",
|
|
214
|
+
"jobId": "...",
|
|
215
|
+
"data": {
|
|
216
|
+
"durationMs": 9700,
|
|
217
|
+
"tokensIn": 2431,
|
|
218
|
+
"tokensOut": 1072,
|
|
219
|
+
"model": "claude-opus-4-7",
|
|
220
|
+
"reportPath": ".skill-map/reports/d-20260420-143055-b001.json"
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### `job.failed`
|
|
226
|
+
|
|
227
|
+
Emitted when a job transitions to `failed` by any path.
|
|
228
|
+
|
|
229
|
+
```json
|
|
230
|
+
{
|
|
231
|
+
"type": "job.failed",
|
|
232
|
+
"timestamp": 1745159465200,
|
|
233
|
+
"runId": "...",
|
|
234
|
+
"jobId": "...",
|
|
235
|
+
"data": {
|
|
236
|
+
"reason": "runner-error | report-invalid | timeout | abandoned | job-file-missing | user-cancelled",
|
|
237
|
+
"message": "Subprocess exited with code 127",
|
|
238
|
+
"exitCode": 127,
|
|
239
|
+
"durationMs": 180000
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`reason` enum matches `execution-record.failureReason`. `message` is human-readable free-form; MAY be truncated for display.
|
|
245
|
+
|
|
246
|
+
### `run.summary`
|
|
247
|
+
|
|
248
|
+
Emitted once at the end of `sm job run`, after the last job event.
|
|
249
|
+
|
|
250
|
+
```json
|
|
251
|
+
{
|
|
252
|
+
"type": "run.summary",
|
|
253
|
+
"timestamp": 1745159475000,
|
|
254
|
+
"runId": "...",
|
|
255
|
+
"jobId": null,
|
|
256
|
+
"data": {
|
|
257
|
+
"jobsAttempted": 5,
|
|
258
|
+
"jobsCompleted": 4,
|
|
259
|
+
"jobsFailed": 1,
|
|
260
|
+
"totalDurationMs": 20000,
|
|
261
|
+
"totalTokensIn": 12500,
|
|
262
|
+
"totalTokensOut": 5300
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`jobsAttempted = jobsCompleted + jobsFailed` always.
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## Ordering rules
|
|
272
|
+
|
|
273
|
+
For each job, the normative order is:
|
|
274
|
+
|
|
275
|
+
```
|
|
276
|
+
job.claimed → job.spawning → (model.delta)* → job.callback.received → (job.completed | job.failed)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
For a run:
|
|
280
|
+
|
|
281
|
+
```
|
|
282
|
+
run.started
|
|
283
|
+
→ run.reap.started → run.reap.completed
|
|
284
|
+
→ (per-job sequence above)*
|
|
285
|
+
→ run.summary
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
A parallel implementation MAY interleave per-job sequences across different `jobId` values, but MUST preserve ordering within a single `jobId`.
|
|
289
|
+
|
|
290
|
+
`job.failed` with reason `abandoned` MAY appear without a matching `job.claimed` in the current run — it refers to a job claimed in a previous run that expired before the next reap.
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Error handling
|
|
295
|
+
|
|
296
|
+
If an event payload cannot be serialized (internal bug), the implementation MUST emit a synthetic event:
|
|
297
|
+
|
|
298
|
+
```json
|
|
299
|
+
{
|
|
300
|
+
"type": "emitter.error",
|
|
301
|
+
"timestamp": <now>,
|
|
302
|
+
"runId": "<runId>",
|
|
303
|
+
"jobId": null,
|
|
304
|
+
"data": {
|
|
305
|
+
"message": "failed to emit event of type '<type>': <reason>"
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Consumers MAY treat `emitter.error` as a soft failure (log and continue). Implementations MUST NOT crash the run because of a serialization failure.
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## Stability
|
|
315
|
+
|
|
316
|
+
The **event type list** above is stable as of spec v1.0.0. Adding a new event type is a minor bump. Removing or renaming one is a major bump.
|
|
317
|
+
|
|
318
|
+
**Adding** fields to `data` is a minor bump. Changing a field's type or removing a field is a major bump.
|
|
319
|
+
|
|
320
|
+
Consumers MUST ignore unknown fields (forward compatibility).
|
|
321
|
+
|
|
322
|
+
The envelope (`type`, `timestamp`, `runId`, `jobId`, `data`) is stable. Adding an envelope field is a major bump because every consumer would need to handle it.
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@skill-map/spec",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "JSON Schemas, prose contracts, and conformance suite for the skill-map specification.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"homepage": "https://skill-map.dev",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/crystian/skill-map.git",
|
|
11
|
+
"directory": "spec"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/crystian/skill-map/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"skill-map",
|
|
18
|
+
"spec",
|
|
19
|
+
"specification",
|
|
20
|
+
"json-schema",
|
|
21
|
+
"ai-agents",
|
|
22
|
+
"markdown",
|
|
23
|
+
"claude-code"
|
|
24
|
+
],
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./index.json",
|
|
27
|
+
"./index.json": "./index.json",
|
|
28
|
+
"./schemas/*.json": "./schemas/*.json"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"README.md",
|
|
32
|
+
"CHANGELOG.md",
|
|
33
|
+
"versioning.md",
|
|
34
|
+
"architecture.md",
|
|
35
|
+
"cli-contract.md",
|
|
36
|
+
"dispatch-lifecycle.md",
|
|
37
|
+
"job-events.md",
|
|
38
|
+
"prompt-preamble.md",
|
|
39
|
+
"db-schema.md",
|
|
40
|
+
"plugin-kv-api.md",
|
|
41
|
+
"interfaces/",
|
|
42
|
+
"schemas/",
|
|
43
|
+
"conformance/",
|
|
44
|
+
"index.json"
|
|
45
|
+
],
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/plugin-kv-api.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Plugin KV API
|
|
2
|
+
|
|
3
|
+
Normative contract for plugin-accessible persistence. Two modes exist (see `db-schema.md` for the catalog entries):
|
|
4
|
+
|
|
5
|
+
- **Mode A — KV**: plugin uses the kernel-provided `ctx.store.*` accessor. Backed by the shared `state_plugin_kv` table.
|
|
6
|
+
- **Mode B — Dedicated**: plugin owns its own tables with the `plugin_<normalizedId>_` prefix, migrated by the kernel.
|
|
7
|
+
|
|
8
|
+
This document defines mode A in full and clarifies the boundary with mode B. Implementations MUST expose this API to every plugin that declares `"storage": { "mode": "kv" }` in its manifest.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Overview
|
|
13
|
+
|
|
14
|
+
A plugin extension receives a `ctx` object at construction time. `ctx.store` is present if and only if the plugin declared storage. Its shape depends on the mode:
|
|
15
|
+
|
|
16
|
+
| Mode | `ctx.store` shape |
|
|
17
|
+
|---|---|
|
|
18
|
+
| No storage declared | `undefined`. |
|
|
19
|
+
| `mode: "kv"` | `KvStore` (this document). |
|
|
20
|
+
| `mode: "dedicated"` | `DedicatedStore` (scoped Database wrapper). See mode B below. |
|
|
21
|
+
|
|
22
|
+
Plugins SHOULD pick the minimum mode they need. Mode A is simpler, deployed across every scope from day zero, and requires no migrations. Mode B is for plugins that need relational shape, indexes, or cross-row queries.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Mode A: `ctx.store` KV accessor
|
|
27
|
+
|
|
28
|
+
### Interface
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
interface KvStore {
|
|
32
|
+
get<T = unknown>(key: string, options?: { nodePath?: string }): Promise<T | null>;
|
|
33
|
+
set<T = unknown>(key: string, value: T, options?: { nodePath?: string }): Promise<void>;
|
|
34
|
+
delete(key: string, options?: { nodePath?: string }): Promise<boolean>;
|
|
35
|
+
list(options?: { nodePath?: string; prefix?: string }): Promise<KvEntry[]>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface KvEntry {
|
|
39
|
+
key: string;
|
|
40
|
+
value: unknown;
|
|
41
|
+
nodePath: string | null;
|
|
42
|
+
updatedAt: number;
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Implementations in other languages MUST expose the same semantic surface.
|
|
47
|
+
|
|
48
|
+
### Scoping
|
|
49
|
+
|
|
50
|
+
Every operation is scoped by the caller's `pluginId`. The plugin cannot specify, override, or observe another plugin's `pluginId`. This is enforced by the kernel when constructing the `ctx.store` — the `pluginId` is captured at registration time and is not an argument.
|
|
51
|
+
|
|
52
|
+
Operations MAY be additionally scoped by `nodePath`:
|
|
53
|
+
|
|
54
|
+
- **Global KV (no `nodePath`)**: `{pluginId, nodePath: null, key}`. One row per plugin + key.
|
|
55
|
+
- **Node-scoped KV (with `nodePath`)**: `{pluginId, nodePath: "<path>", key}`. One row per plugin + node + key.
|
|
56
|
+
|
|
57
|
+
Both scopes share the same underlying `state_plugin_kv` table (see `db-schema.md`). The `nodePath` column is nullable; implementations MUST use a sentinel empty string internally when the backing engine rejects NULL in composite primary keys.
|
|
58
|
+
|
|
59
|
+
### Semantics
|
|
60
|
+
|
|
61
|
+
| Operation | Behaviour |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `get(key, { nodePath })` | Returns the stored value (JSON-decoded) or `null` if no row exists. Never throws for "missing". |
|
|
64
|
+
| `set(key, value, { nodePath })` | Upsert. Replaces any existing value. Updates `updatedAt`. The value is JSON-encoded by the kernel; it MUST be JSON-serializable. Cyclic or non-serializable values MUST be rejected with a typed error. |
|
|
65
|
+
| `delete(key, { nodePath })` | Deletes the row if present. Returns `true` if a row was deleted, `false` otherwise. Idempotent. |
|
|
66
|
+
| `list({ nodePath, prefix })` | Returns all entries matching the scope. `nodePath` omitted: returns global entries (`nodePath IS NULL`). `nodePath: null` (explicit): same as omitted. `nodePath: "<path>"`: returns entries for that node. `prefix`: filters keys starting with the given string. |
|
|
67
|
+
|
|
68
|
+
Return order of `list` is NOT specified by this spec; consumers MUST NOT rely on ordering. Implementations SHOULD order by `key ASC` for developer ergonomics.
|
|
69
|
+
|
|
70
|
+
### Key constraints
|
|
71
|
+
|
|
72
|
+
- `key` MUST be a non-empty string, length ≤ 256 bytes (UTF-8).
|
|
73
|
+
- `key` SHOULD be dot-separated namespaces (`foo.bar.baz`) for discoverability, but this is not enforced.
|
|
74
|
+
- The kernel MAY log a warning when `key` exceeds a reasonable length (e.g. 128), but MUST NOT reject below 256.
|
|
75
|
+
|
|
76
|
+
### Value constraints
|
|
77
|
+
|
|
78
|
+
- Value MUST be JSON-serializable (plain objects, arrays, strings, numbers, booleans, null).
|
|
79
|
+
- Values containing `undefined` or functions MUST be rejected with a typed error before writing.
|
|
80
|
+
- The kernel MAY impose a per-value size limit (reference impl: 1 MiB). Exceeding it is a typed error, not a silent truncation.
|
|
81
|
+
|
|
82
|
+
### Transactions
|
|
83
|
+
|
|
84
|
+
The `KvStore` operations are individually atomic. There is NO multi-operation transaction in mode A — plugins that need transactional semantics across several rows MUST use mode B.
|
|
85
|
+
|
|
86
|
+
Implementations MUST NOT expose a `transaction()` method on `KvStore` in mode A. The shape is intentionally minimal to keep the backing table simple.
|
|
87
|
+
|
|
88
|
+
### Errors
|
|
89
|
+
|
|
90
|
+
All errors are typed. An implementation MUST expose these error classes (or language equivalents):
|
|
91
|
+
|
|
92
|
+
| Error | Cause |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `KvKeyInvalidError` | Key is empty, non-string, or too long. |
|
|
95
|
+
| `KvValueNotSerializableError` | Value cannot be JSON-encoded. |
|
|
96
|
+
| `KvValueTooLargeError` | Encoded value exceeds the size limit. |
|
|
97
|
+
| `KvOperationFailedError` | Unexpected backend failure (e.g., DB full, IO error). Wraps the underlying cause. |
|
|
98
|
+
|
|
99
|
+
Errors MUST NOT leak backend-specific details (SQL strings, file paths) to plugin code unless wrapped in `KvOperationFailedError.cause`.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Mode B: dedicated tables
|
|
104
|
+
|
|
105
|
+
Mode B is governed by `db-schema.md` (catalog rules + triple protection). This section restates the API surface.
|
|
106
|
+
|
|
107
|
+
### Declaration
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"storage": {
|
|
112
|
+
"mode": "dedicated",
|
|
113
|
+
"tables": ["rule_exceptions", "cache_entries"],
|
|
114
|
+
"migrations": ["migrations/001_initial.sql"]
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The `tables` array lists logical table names **without** the `plugin_<id>_` prefix. The kernel prepends the prefix when applying migrations and when routing queries.
|
|
120
|
+
|
|
121
|
+
### Accessor
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
interface DedicatedStore {
|
|
125
|
+
db: Database; // scoped wrapper, see below
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`DedicatedStore.db` is a wrapper — NOT a raw handle. Every query passes through a validator that rejects:
|
|
130
|
+
|
|
131
|
+
- References to tables whose name doesn't start with this plugin's prefix.
|
|
132
|
+
- DDL statements (`CREATE`, `ALTER`, `DROP`, `TRUNCATE`). Mode B DDL is runtime-immutable after migrations; plugins change shape via a new migration, not at runtime.
|
|
133
|
+
- `ATTACH DATABASE` statements.
|
|
134
|
+
- `PRAGMA` statements that aren't scoped to the plugin's own tables.
|
|
135
|
+
|
|
136
|
+
A query that fails validation raises `ScopedDbViolationError`. The plugin continues to run; only the offending query is rejected.
|
|
137
|
+
|
|
138
|
+
### Transaction support
|
|
139
|
+
|
|
140
|
+
Mode B plugins MAY call `db.transaction(async (tx) => { ... })`. The kernel provides transaction isolation consistent with the backing engine. Nested transactions are NOT supported; the kernel MUST reject a nested `transaction()` call with a typed error.
|
|
141
|
+
|
|
142
|
+
### Migrations
|
|
143
|
+
|
|
144
|
+
- Location: `<plugin-dir>/migrations/NNN_snake_case.sql`.
|
|
145
|
+
- Applied in order after kernel migrations on boot.
|
|
146
|
+
- Prefix injection: the kernel rewrites `CREATE TABLE <name>` into `CREATE TABLE plugin_<id>_<name>` if the prefix is missing.
|
|
147
|
+
- Index and constraint prefixes are similarly injected.
|
|
148
|
+
- A failing plugin migration disables only that plugin (`status: load-error`); other plugins and the kernel continue.
|
|
149
|
+
|
|
150
|
+
See `db-schema.md` for the normative migration rules.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Mode selection guidance
|
|
155
|
+
|
|
156
|
+
Non-normative; descriptive guidance for plugin authors.
|
|
157
|
+
|
|
158
|
+
**Prefer mode A when**:
|
|
159
|
+
|
|
160
|
+
- Each value is a small JSON blob (preferences, per-node flags, hash pins).
|
|
161
|
+
- Queries are "get by key" or "list under a prefix".
|
|
162
|
+
- You need to ship without asking the user to run a migration.
|
|
163
|
+
|
|
164
|
+
**Prefer mode B when**:
|
|
165
|
+
|
|
166
|
+
- You need indexes beyond `(pluginId, nodePath, key)`.
|
|
167
|
+
- You need to `JOIN` rows, aggregate, or do relational queries.
|
|
168
|
+
- Your data model is actually tabular (cache with TTL, observation log, provider registry).
|
|
169
|
+
- You are willing to own migrations forever.
|
|
170
|
+
|
|
171
|
+
A plugin MUST declare **exactly one** storage mode. Mixing modes in the same plugin is forbidden. The `plugins-registry.schema.json` enforces this at the manifest level (`storage` is a `oneOf` between `kv` and `dedicated`), and at runtime `ctx.store` exposes either the `KvStore` or the `DedicatedStore` shape — never both. A plugin that needs both KV-like and relational access MUST use mode B and implement KV-style rows as a dedicated table.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Visibility rules
|
|
176
|
+
|
|
177
|
+
- A plugin MUST NOT read or write rows outside its scope. Mode A: the accessor is scoped. Mode B: the validator enforces the prefix.
|
|
178
|
+
- The kernel MAY expose read-only introspection for diagnostics (e.g., `sm plugins show <id> --storage` lists key counts). This is authoritative, not a plugin-level API.
|
|
179
|
+
- `sm db shell` can read any table. This is an operator-level escape hatch; plugins MUST NOT rely on it.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Backup and retention
|
|
184
|
+
|
|
185
|
+
- Mode A rows are stored in `state_plugin_kv` and are backed up with `sm db backup`.
|
|
186
|
+
- Mode B rows live in the plugin's dedicated tables, prefixed `plugin_<id>_`, and are likewise backed up.
|
|
187
|
+
- `sm plugins disable <id>` does NOT drop the plugin's data — disabled plugins keep their KV rows and dedicated tables. `sm plugins forget <id>` (post-MVP) is the verb that wipes.
|
|
188
|
+
- `sm db reset` drops `scan_*` + `state_*`. This includes `state_plugin_kv` (mode A) AND every `plugin_<id>_*` table (mode B). Users MUST be warned by the CLI before `reset` proceeds.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Honest note on isolation
|
|
193
|
+
|
|
194
|
+
Mode A is perfectly isolated at the row level: the accessor physically cannot see another plugin's rows.
|
|
195
|
+
|
|
196
|
+
Mode B is **isolated against accidents, not hostile code**. The scoped `Database` wrapper rejects cross-namespace queries at runtime. But a malicious plugin running in the same JavaScript process can bypass the wrapper by importing raw engine bindings directly. Plugins are user-placed code; the kernel trusts the user's judgement at install time.
|
|
197
|
+
|
|
198
|
+
Post-v1.0 work: signed manifest, sandboxed worker-thread isolation, per-plugin DB file. None of these land before cut 1.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Stability
|
|
203
|
+
|
|
204
|
+
- The `KvStore` interface (method names, options, return shapes) is **stable** as of spec v1.0.0.
|
|
205
|
+
- Adding a method to `KvStore` is a minor bump; removing or changing signature is a major bump.
|
|
206
|
+
- Mode names (`kv`, `dedicated`) are **stable**. Adding a third mode is a minor bump.
|
|
207
|
+
- Key and value size limits are implementation-defined and MAY change without a spec bump; implementations MUST document their limits in their own changelog.
|
|
208
|
+
- Error class names are **stable**; adding a new error class is a minor bump.
|