@reunionstudio/airlock-mcp 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/.agents/skills/airlock-mcp/SKILL.md +122 -0
- package/.agents/skills/airlock-mcp/agents/openai.yaml +4 -0
- package/LICENSE +187 -0
- package/README.md +126 -0
- package/SECURITY.md +31 -0
- package/bin/airlock-mcp.mjs +5 -0
- package/docs/architecture.md +82 -0
- package/docs/install-surface.md +112 -0
- package/docs/ooda-loop.md +40 -0
- package/docs/spec-workbench-architecture.md +161 -0
- package/docs/spec-workspace.md +33 -0
- package/docs/workflows.md +229 -0
- package/package.json +46 -0
- package/patterns/blank/README.md +14 -0
- package/patterns/blank/sample.records.json +19 -0
- package/patterns/blank/spec.config.json +72 -0
- package/patterns/guest-access/individual-isolation.md +27 -0
- package/patterns/guest-access/role-isolation.md +26 -0
- package/patterns/guest-access/shared-contribution.md +25 -0
- package/patterns/manifest.json +16 -0
- package/patterns/spec-types/commitment.md +24 -0
- package/patterns/spec-types/observation.md +22 -0
- package/patterns/spec-types/reconciliation.md +19 -0
- package/patterns/spec-types/reference-master-data.md +21 -0
- package/patterns/starter-posts/README.md +32 -0
- package/patterns/starter-posts/sample.records.json +27 -0
- package/patterns/starter-posts/spec.config.json +135 -0
- package/schemas/airlock-mcp-workspace.schema.json +14 -0
- package/setup.py +41 -0
- package/src/airlock_mcp/__init__.py +3 -0
- package/src/airlock_mcp/__main__.py +5 -0
- package/src/airlock_mcp/art.py +26 -0
- package/src/airlock_mcp/bootstrap.py +161 -0
- package/src/airlock_mcp/cli.py +450 -0
- package/src/airlock_mcp/jsonio.py +56 -0
- package/src/airlock_mcp/manage.py +247 -0
- package/src/airlock_mcp/models.py +43 -0
- package/src/airlock_mcp/patterns.py +49 -0
- package/src/airlock_mcp/project.py +39 -0
- package/src/airlock_mcp/records.py +43 -0
- package/src/airlock_mcp/specs.py +110 -0
- package/src/airlock_mcp/sql.py +15 -0
- package/src/airlock_mcp/summary.py +115 -0
- package/src/airlock_mcp/updater.py +76 -0
- package/src/airlock_mcp/validation.py +334 -0
- package/src/airlock_mcp/workspace.py +223 -0
- package/src/cli.mjs +89 -0
- package/src/install.mjs +100 -0
- package/src/mcp.mjs +184 -0
- package/src/text.mjs +108 -0
- package/src/workbench.mjs +368 -0
- package/workspaces/_template/brief.md +18 -0
- package/workspaces/_template/decisions.md +40 -0
- package/workspaces/_template/questions.md +9 -0
- package/workspaces/_template/review.md +21 -0
- package/workspaces/_template/sample.records.json +19 -0
- package/workspaces/_template/spec.config.json +72 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const MAX_ARG_LENGTH = 1000;
|
|
7
|
+
const OUTPUT_MAX_BUFFER = 1024 * 1024 * 4;
|
|
8
|
+
|
|
9
|
+
const cwdProperty = {
|
|
10
|
+
type: "string",
|
|
11
|
+
description: "Directory to run from. Defaults to the MCP server working directory.",
|
|
12
|
+
maxLength: MAX_ARG_LENGTH,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const forceProperty = {
|
|
16
|
+
type: "boolean",
|
|
17
|
+
description: "Overwrite existing generated files when the underlying command supports it.",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const workspaceProperty = {
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "Workspace path, such as workspaces/feedback-loop.",
|
|
23
|
+
maxLength: MAX_ARG_LENGTH,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const WORKBENCH_TOOLS = [
|
|
27
|
+
{
|
|
28
|
+
name: "airlock_doctor",
|
|
29
|
+
description: "Check that bundled Airlock MCP workbench assets are available.",
|
|
30
|
+
inputSchema: objectSchema({ cwd: cwdProperty }),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "airlock_init_repo",
|
|
34
|
+
description: "Bootstrap a specs repo with AGENTS.md, the Airlock MCP skill, and workspaces/.",
|
|
35
|
+
inputSchema: objectSchema({
|
|
36
|
+
path: {
|
|
37
|
+
type: "string",
|
|
38
|
+
description: "Specs repo path to initialize. Defaults to the current directory.",
|
|
39
|
+
maxLength: MAX_ARG_LENGTH,
|
|
40
|
+
},
|
|
41
|
+
force: forceProperty,
|
|
42
|
+
cwd: cwdProperty,
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "airlock_list_patterns",
|
|
47
|
+
description: "List bundled Airlock spec starter patterns.",
|
|
48
|
+
inputSchema: objectSchema({ cwd: cwdProperty }),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "airlock_show_pattern",
|
|
52
|
+
description: "Show a bundled starter pattern README or its file paths.",
|
|
53
|
+
inputSchema: objectSchema(
|
|
54
|
+
{
|
|
55
|
+
pattern: {
|
|
56
|
+
type: "string",
|
|
57
|
+
enum: ["blank", "posts"],
|
|
58
|
+
description: "Pattern to inspect.",
|
|
59
|
+
},
|
|
60
|
+
files: {
|
|
61
|
+
type: "boolean",
|
|
62
|
+
description: "Print pattern file paths instead of the README.",
|
|
63
|
+
},
|
|
64
|
+
cwd: cwdProperty,
|
|
65
|
+
},
|
|
66
|
+
["pattern"],
|
|
67
|
+
),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "airlock_init_workspace",
|
|
71
|
+
description: "Create a spec workspace from a bundled pattern.",
|
|
72
|
+
inputSchema: objectSchema(
|
|
73
|
+
{
|
|
74
|
+
name: {
|
|
75
|
+
type: "string",
|
|
76
|
+
description: "Workspace folder name, such as feedback-loop.",
|
|
77
|
+
maxLength: 160,
|
|
78
|
+
},
|
|
79
|
+
pattern: {
|
|
80
|
+
type: "string",
|
|
81
|
+
enum: ["blank", "posts"],
|
|
82
|
+
description: "Starting pattern. Defaults to blank.",
|
|
83
|
+
},
|
|
84
|
+
output: {
|
|
85
|
+
type: "string",
|
|
86
|
+
description: "Parent directory for the workspace. Defaults to ./workspaces.",
|
|
87
|
+
maxLength: MAX_ARG_LENGTH,
|
|
88
|
+
},
|
|
89
|
+
force: forceProperty,
|
|
90
|
+
cwd: cwdProperty,
|
|
91
|
+
},
|
|
92
|
+
["name"],
|
|
93
|
+
),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "airlock_list_workspaces",
|
|
97
|
+
description: "List active or archived Airlock spec workspaces.",
|
|
98
|
+
inputSchema: objectSchema({
|
|
99
|
+
root: {
|
|
100
|
+
type: "string",
|
|
101
|
+
description: "Workspace root. Defaults to ./workspaces.",
|
|
102
|
+
maxLength: MAX_ARG_LENGTH,
|
|
103
|
+
},
|
|
104
|
+
all: {
|
|
105
|
+
type: "boolean",
|
|
106
|
+
description: "Include archived workspaces.",
|
|
107
|
+
},
|
|
108
|
+
paths: {
|
|
109
|
+
type: "boolean",
|
|
110
|
+
description: "Print only workspace paths.",
|
|
111
|
+
},
|
|
112
|
+
cwd: cwdProperty,
|
|
113
|
+
}),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "airlock_check_workspace",
|
|
117
|
+
description: "Run local validation against a spec workspace.",
|
|
118
|
+
inputSchema: objectSchema({ workspace: workspaceProperty, cwd: cwdProperty }, ["workspace"]),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "airlock_summary",
|
|
122
|
+
description: "Summarize a spec workspace for session re-entry.",
|
|
123
|
+
inputSchema: objectSchema({ workspace: workspaceProperty, cwd: cwdProperty }, ["workspace"]),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "airlock_next",
|
|
127
|
+
description: "Show the next safe action for a spec workspace.",
|
|
128
|
+
inputSchema: objectSchema({ workspace: workspaceProperty, cwd: cwdProperty }, ["workspace"]),
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "airlock_export_csv",
|
|
132
|
+
description: "Render sample.records.json as Airlock-ready CSV.",
|
|
133
|
+
inputSchema: objectSchema(
|
|
134
|
+
{
|
|
135
|
+
workspace: workspaceProperty,
|
|
136
|
+
output: {
|
|
137
|
+
type: "string",
|
|
138
|
+
description: "Optional output CSV path. If omitted, CSV is returned in tool output.",
|
|
139
|
+
maxLength: MAX_ARG_LENGTH,
|
|
140
|
+
},
|
|
141
|
+
force: forceProperty,
|
|
142
|
+
cwd: cwdProperty,
|
|
143
|
+
},
|
|
144
|
+
["workspace"],
|
|
145
|
+
),
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "airlock_render_sql",
|
|
149
|
+
description: "Render validate-only Airlock admin SQL for a workspace.",
|
|
150
|
+
inputSchema: objectSchema(
|
|
151
|
+
{
|
|
152
|
+
workspace: workspaceProperty,
|
|
153
|
+
app: {
|
|
154
|
+
type: "string",
|
|
155
|
+
description: "Installed Airlock app name. Defaults to airlock.",
|
|
156
|
+
maxLength: 160,
|
|
157
|
+
},
|
|
158
|
+
operation: {
|
|
159
|
+
type: "string",
|
|
160
|
+
enum: ["create", "alter"],
|
|
161
|
+
description: "Procedure shape to render. Defaults to create.",
|
|
162
|
+
},
|
|
163
|
+
force: forceProperty,
|
|
164
|
+
cwd: cwdProperty,
|
|
165
|
+
},
|
|
166
|
+
["workspace"],
|
|
167
|
+
),
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
const WORKBENCH_TOOL_NAMES = new Set(WORKBENCH_TOOLS.map((tool) => tool.name));
|
|
172
|
+
|
|
173
|
+
function objectSchema(properties, required = []) {
|
|
174
|
+
return {
|
|
175
|
+
type: "object",
|
|
176
|
+
properties,
|
|
177
|
+
required,
|
|
178
|
+
additionalProperties: false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function packageRoot() {
|
|
183
|
+
return path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function optionalString(args, name, fallback) {
|
|
187
|
+
const value = args?.[name];
|
|
188
|
+
if (value === undefined || value === null || value === "") {
|
|
189
|
+
return fallback;
|
|
190
|
+
}
|
|
191
|
+
if (typeof value !== "string") {
|
|
192
|
+
throw new Error(`${name} must be a string`);
|
|
193
|
+
}
|
|
194
|
+
if (value.length > MAX_ARG_LENGTH || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
195
|
+
throw new Error(`${name} must be ${MAX_ARG_LENGTH} characters or fewer with no control characters`);
|
|
196
|
+
}
|
|
197
|
+
if (value.startsWith("-")) {
|
|
198
|
+
throw new Error(`${name} must not start with '-'`);
|
|
199
|
+
}
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function requiredString(args, name) {
|
|
204
|
+
const value = optionalString(args, name, undefined);
|
|
205
|
+
if (!value) {
|
|
206
|
+
throw new Error(`${name} is required`);
|
|
207
|
+
}
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function optionalBoolean(args, name, fallback = false) {
|
|
212
|
+
const value = args?.[name];
|
|
213
|
+
if (value === undefined || value === null) {
|
|
214
|
+
return fallback;
|
|
215
|
+
}
|
|
216
|
+
if (typeof value !== "boolean") {
|
|
217
|
+
throw new Error(`${name} must be a boolean`);
|
|
218
|
+
}
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function optionalEnum(args, name, allowed, fallback) {
|
|
223
|
+
const value = optionalString(args, name, fallback);
|
|
224
|
+
if (value === undefined) {
|
|
225
|
+
return fallback;
|
|
226
|
+
}
|
|
227
|
+
if (!allowed.includes(value)) {
|
|
228
|
+
throw new Error(`${name} must be one of: ${allowed.join(", ")}`);
|
|
229
|
+
}
|
|
230
|
+
return value;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function requiredEnum(args, name, allowed) {
|
|
234
|
+
const value = optionalEnum(args, name, allowed, undefined);
|
|
235
|
+
if (value === undefined) {
|
|
236
|
+
throw new Error(`${name} is required`);
|
|
237
|
+
}
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function resolveCwd(args) {
|
|
242
|
+
const cwd = optionalString(args, "cwd", process.cwd());
|
|
243
|
+
const resolved = path.resolve(cwd);
|
|
244
|
+
if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
|
|
245
|
+
throw new Error(`cwd is not a directory: ${cwd}`);
|
|
246
|
+
}
|
|
247
|
+
return resolved;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function maybePush(args, flag, value) {
|
|
251
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
252
|
+
args.push(flag, value);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function cliArgsForTool(name, args = {}) {
|
|
257
|
+
const cwd = resolveCwd(args);
|
|
258
|
+
const force = optionalBoolean(args, "force");
|
|
259
|
+
switch (name) {
|
|
260
|
+
case "airlock_doctor":
|
|
261
|
+
return { cwd, cliArgs: ["doctor"] };
|
|
262
|
+
case "airlock_init_repo": {
|
|
263
|
+
const cliArgs = ["init-repo", optionalString(args, "path", ".")];
|
|
264
|
+
if (force) cliArgs.push("--force");
|
|
265
|
+
return { cwd, cliArgs };
|
|
266
|
+
}
|
|
267
|
+
case "airlock_list_patterns":
|
|
268
|
+
return { cwd, cliArgs: ["list-patterns"] };
|
|
269
|
+
case "airlock_show_pattern": {
|
|
270
|
+
const cliArgs = ["show-pattern", requiredEnum(args, "pattern", ["blank", "posts"])];
|
|
271
|
+
if (optionalBoolean(args, "files")) cliArgs.push("--files");
|
|
272
|
+
return { cwd, cliArgs };
|
|
273
|
+
}
|
|
274
|
+
case "airlock_init_workspace": {
|
|
275
|
+
const cliArgs = ["init", requiredString(args, "name"), "--pattern", optionalEnum(args, "pattern", ["blank", "posts"], "blank")];
|
|
276
|
+
maybePush(cliArgs, "--output", optionalString(args, "output", undefined));
|
|
277
|
+
if (force) cliArgs.push("--force");
|
|
278
|
+
return { cwd, cliArgs };
|
|
279
|
+
}
|
|
280
|
+
case "airlock_list_workspaces": {
|
|
281
|
+
const cliArgs = ["list-workspaces"];
|
|
282
|
+
maybePush(cliArgs, "--root", optionalString(args, "root", undefined));
|
|
283
|
+
if (optionalBoolean(args, "all")) cliArgs.push("--all");
|
|
284
|
+
if (optionalBoolean(args, "paths")) cliArgs.push("--paths");
|
|
285
|
+
return { cwd, cliArgs };
|
|
286
|
+
}
|
|
287
|
+
case "airlock_check_workspace":
|
|
288
|
+
return { cwd, cliArgs: ["check", requiredString(args, "workspace")] };
|
|
289
|
+
case "airlock_summary":
|
|
290
|
+
return { cwd, cliArgs: ["summary", requiredString(args, "workspace")] };
|
|
291
|
+
case "airlock_next":
|
|
292
|
+
return { cwd, cliArgs: ["next", requiredString(args, "workspace")] };
|
|
293
|
+
case "airlock_export_csv": {
|
|
294
|
+
const cliArgs = ["export-csv", requiredString(args, "workspace")];
|
|
295
|
+
maybePush(cliArgs, "--output", optionalString(args, "output", undefined));
|
|
296
|
+
if (force) cliArgs.push("--force");
|
|
297
|
+
return { cwd, cliArgs };
|
|
298
|
+
}
|
|
299
|
+
case "airlock_render_sql": {
|
|
300
|
+
const cliArgs = ["render-sql", requiredString(args, "workspace")];
|
|
301
|
+
maybePush(cliArgs, "--app", optionalString(args, "app", undefined));
|
|
302
|
+
maybePush(cliArgs, "--operation", optionalEnum(args, "operation", ["create", "alter"], undefined));
|
|
303
|
+
if (force) cliArgs.push("--force");
|
|
304
|
+
return { cwd, cliArgs };
|
|
305
|
+
}
|
|
306
|
+
default:
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function runWorkbenchCli(cliArgs, { cwd, env = process.env, spawn = spawnSync } = {}) {
|
|
312
|
+
const root = packageRoot();
|
|
313
|
+
const python = env.AIRLOCK_MCP_PYTHON || env.PYTHON || "python3";
|
|
314
|
+
const pythonPath = [path.join(root, "src"), env.PYTHONPATH].filter(Boolean).join(path.delimiter);
|
|
315
|
+
const result = spawn(python, ["-m", "airlock_mcp", ...cliArgs], {
|
|
316
|
+
cwd,
|
|
317
|
+
encoding: "utf8",
|
|
318
|
+
maxBuffer: OUTPUT_MAX_BUFFER,
|
|
319
|
+
env: {
|
|
320
|
+
...env,
|
|
321
|
+
AIRLOCK_MCP_HOME: root,
|
|
322
|
+
PYTHONPATH: pythonPath,
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
if (result.error) {
|
|
327
|
+
return {
|
|
328
|
+
status: 127,
|
|
329
|
+
stdout: "",
|
|
330
|
+
stderr: `error: could not run ${python}: ${result.error.message}\n`,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
status: typeof result.status === "number" ? result.status : 1,
|
|
336
|
+
stdout: result.stdout || "",
|
|
337
|
+
stderr: result.stderr || "",
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function callWorkbenchTool(name, args = {}) {
|
|
342
|
+
if (!WORKBENCH_TOOL_NAMES.has(name)) {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
const plan = cliArgsForTool(name, args);
|
|
346
|
+
const result = runWorkbenchCli(plan.cliArgs, { cwd: plan.cwd });
|
|
347
|
+
return formatCliToolResult(result);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function formatCliToolResult(result) {
|
|
351
|
+
const chunks = [];
|
|
352
|
+
if (result.stdout.trim()) {
|
|
353
|
+
chunks.push(result.stdout.trimEnd());
|
|
354
|
+
}
|
|
355
|
+
if (result.stderr.trim()) {
|
|
356
|
+
chunks.push(`stderr:\n${result.stderr.trimEnd()}`);
|
|
357
|
+
}
|
|
358
|
+
if (!chunks.length) {
|
|
359
|
+
chunks.push(result.status === 0 ? "ok" : `airlock-mcp exited with status ${result.status}`);
|
|
360
|
+
}
|
|
361
|
+
if (result.status !== 0) {
|
|
362
|
+
chunks.unshift(`airlock-mcp exited with status ${result.status}.`);
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
content: [{ type: "text", text: chunks.join("\n\n") }],
|
|
366
|
+
...(result.status === 0 ? {} : { isError: true }),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Spec Brief
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Describe the smallest useful governed output this spec should produce.
|
|
6
|
+
|
|
7
|
+
## Users And Agents
|
|
8
|
+
|
|
9
|
+
Who submits, reviews, reads, delegates, or acts on this data?
|
|
10
|
+
|
|
11
|
+
## Systems To Observe
|
|
12
|
+
|
|
13
|
+
List the systems, files, screenshots, APIs, users, or existing Airlock specs
|
|
14
|
+
that inform the decision.
|
|
15
|
+
|
|
16
|
+
## First Useful Outcome
|
|
17
|
+
|
|
18
|
+
What should become possible after the first version lands in Airlock?
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Decisions
|
|
2
|
+
|
|
3
|
+
## Row Grain
|
|
4
|
+
|
|
5
|
+
One row is:
|
|
6
|
+
|
|
7
|
+
## OODA Loop
|
|
8
|
+
|
|
9
|
+
- Observe:
|
|
10
|
+
- Orient:
|
|
11
|
+
- Decide:
|
|
12
|
+
- Act:
|
|
13
|
+
|
|
14
|
+
## Identifiers
|
|
15
|
+
|
|
16
|
+
Stable ids and retry-safe keys:
|
|
17
|
+
|
|
18
|
+
## Business Time
|
|
19
|
+
|
|
20
|
+
Event, observed, captured, effective, or transaction timestamps:
|
|
21
|
+
|
|
22
|
+
## Typed Columns
|
|
23
|
+
|
|
24
|
+
Fields people will filter, join, audit, aggregate, or report on:
|
|
25
|
+
|
|
26
|
+
## Variant Context
|
|
27
|
+
|
|
28
|
+
Optional context that may evolve:
|
|
29
|
+
|
|
30
|
+
## Evidence
|
|
31
|
+
|
|
32
|
+
Attachments and evidence metadata:
|
|
33
|
+
|
|
34
|
+
## Access
|
|
35
|
+
|
|
36
|
+
Submitter, reviewer, reader, owner, and delegation model:
|
|
37
|
+
|
|
38
|
+
## Workflow And Expectations
|
|
39
|
+
|
|
40
|
+
States, pushback, due dates, order, or cadence:
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"spec_name": "draft_spec",
|
|
3
|
+
"filename": "draft_001",
|
|
4
|
+
"records": [
|
|
5
|
+
{
|
|
6
|
+
"record_id": "REC-001",
|
|
7
|
+
"observed_at": "2026-06-13 09:00:00",
|
|
8
|
+
"summary": "Example draft record.",
|
|
9
|
+
"details": {
|
|
10
|
+
"source": {
|
|
11
|
+
"system": "manual"
|
|
12
|
+
},
|
|
13
|
+
"notes": {
|
|
14
|
+
"reason": "Used to test the draft shape."
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"core_config": {
|
|
3
|
+
"spec_name": "draft_spec",
|
|
4
|
+
"spec_alias": "Draft Spec",
|
|
5
|
+
"description": "Draft Airlock spec.",
|
|
6
|
+
"owner_role": "app_admin",
|
|
7
|
+
"is_published": false,
|
|
8
|
+
"is_archived": false
|
|
9
|
+
},
|
|
10
|
+
"column_config": [
|
|
11
|
+
{
|
|
12
|
+
"name": "record_id",
|
|
13
|
+
"type": "string",
|
|
14
|
+
"description": "Stable record identifier.",
|
|
15
|
+
"tests": ["not_null", "unique"]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "observed_at",
|
|
19
|
+
"type": "datetime",
|
|
20
|
+
"description": "Business timestamp for the observed event.",
|
|
21
|
+
"format": "%Y-%m-%d %H:%M:%S",
|
|
22
|
+
"tests": ["not_null"]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "summary",
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Short business-readable summary.",
|
|
28
|
+
"tests": ["not_null"]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"name": "details",
|
|
32
|
+
"type": "variant",
|
|
33
|
+
"description": "Optional validated context.",
|
|
34
|
+
"tests": []
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"rules": [
|
|
38
|
+
{
|
|
39
|
+
"type": "variant_shape",
|
|
40
|
+
"field": "details",
|
|
41
|
+
"allowed_root_keys": ["source", "notes"],
|
|
42
|
+
"paths": [
|
|
43
|
+
{
|
|
44
|
+
"json_path": "$.source.system",
|
|
45
|
+
"type": "string",
|
|
46
|
+
"required": false
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"json_path": "$.notes.reason",
|
|
50
|
+
"type": "string",
|
|
51
|
+
"required": false
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"file_rules": {
|
|
57
|
+
"file_format": {
|
|
58
|
+
"file_type": "csv",
|
|
59
|
+
"record_delimiter": "\n",
|
|
60
|
+
"field_delimiter": ",",
|
|
61
|
+
"field_optionally_enclosed_by": "\"",
|
|
62
|
+
"escape_unenclosed_field": "\\",
|
|
63
|
+
"encoding": "UTF8",
|
|
64
|
+
"parse_header": true,
|
|
65
|
+
"save_header": true
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
"attachment_policy": {
|
|
69
|
+
"attachments_enabled": true,
|
|
70
|
+
"attachment_required": false
|
|
71
|
+
}
|
|
72
|
+
}
|