@awebai/oats 0.22.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.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/bin/oats.mjs +3294 -0
  4. package/capabilities/oats-authoring/oats.json +12 -0
  5. package/capabilities/oats-aweb/bin/oats-aweb.mjs +297 -0
  6. package/capabilities/oats-aweb/injects/aweb.md +55 -0
  7. package/capabilities/oats-aweb/oats.json +47 -0
  8. package/capabilities/oats-aweb/package.json +20 -0
  9. package/capabilities/oats-jira/bin/oats-jira.mjs +40 -0
  10. package/capabilities/oats-jira/injects/jira.md +10 -0
  11. package/capabilities/oats-jira/oats.json +22 -0
  12. package/capabilities/oats-jira/package.json +25 -0
  13. package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +176 -0
  14. package/capabilities/oats-linear/README.md +234 -0
  15. package/capabilities/oats-linear/bin/oats-linear-hook.mjs +32 -0
  16. package/capabilities/oats-linear/bin/oats-linear.mjs +344 -0
  17. package/capabilities/oats-linear/injects/linear.md +8 -0
  18. package/capabilities/oats-linear/oats.json +24 -0
  19. package/capabilities/oats-linear/package.json +29 -0
  20. package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +217 -0
  21. package/capabilities/oats-linear/test/oats-linear.test.mjs +168 -0
  22. package/capabilities/oats-okf/agents/memory-harvest.md +25 -0
  23. package/capabilities/oats-okf/bin/oats-okf.mjs +283 -0
  24. package/capabilities/oats-okf/injects/okf.md +62 -0
  25. package/capabilities/oats-okf/oats.json +20 -0
  26. package/capabilities/oats-okf/package.json +22 -0
  27. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +100 -0
  28. package/capabilities/oats-okf/skills/okf/SKILL.md +107 -0
  29. package/capabilities/oats-okf/skills/okf/scripts/okf-validate.mjs +123 -0
  30. package/capabilities/oats-review/agents/reviewer/AGENTS.md +53 -0
  31. package/capabilities/oats-review/agents/reviewer/soul.yaml +6 -0
  32. package/capabilities/oats-review/injects/review.md +68 -0
  33. package/capabilities/oats-review/oats.json +10 -0
  34. package/capabilities/oats-review/skills/code-review/SKILL.md +44 -0
  35. package/capabilities/oats-review/skills/security-review/SKILL.md +59 -0
  36. package/docs/capabilities.md +505 -0
  37. package/docs/capability-manifest.schema.json +223 -0
  38. package/docs/configuration.md +482 -0
  39. package/docs/conventions.md +73 -0
  40. package/docs/design/desktop-ux-plan.md +362 -0
  41. package/docs/design/package-engine-contract.md +813 -0
  42. package/docs/design/package-runtime-api.md +414 -0
  43. package/docs/desktop-cli-api.md +89 -0
  44. package/docs/desktop-succession.md +51 -0
  45. package/docs/desktop.md +187 -0
  46. package/docs/implementation.md +282 -0
  47. package/docs/integrations.md +123 -0
  48. package/docs/knowledge-theory.md +97 -0
  49. package/docs/knowledge.md +139 -0
  50. package/docs/layers.md +110 -0
  51. package/docs/migration-from-oas.md +122 -0
  52. package/docs/oats-config.schema.json +140 -0
  53. package/docs/oats-lock.schema.json +175 -0
  54. package/docs/oats-package.schema.json +129 -0
  55. package/docs/packages.md +442 -0
  56. package/docs/release-lane.md +132 -0
  57. package/docs/release-notes/v0.18.2.md +79 -0
  58. package/docs/release-notes/v0.18.3.md +63 -0
  59. package/docs/release-notes/v0.18.4.md +65 -0
  60. package/docs/release-notes/v0.18.5.md +66 -0
  61. package/docs/release-notes/v0.18.6.md +87 -0
  62. package/docs/release-notes/v0.19.0.md +186 -0
  63. package/docs/release-notes/v0.19.1.md +24 -0
  64. package/docs/release-notes/v0.19.2.md +32 -0
  65. package/docs/release-notes/v0.19.3.md +16 -0
  66. package/docs/release-notes/v0.19.4.md +12 -0
  67. package/docs/release-notes/v0.20.0.md +89 -0
  68. package/docs/release-notes/v0.22.0.md +180 -0
  69. package/docs/souls-and-instances.md +344 -0
  70. package/injects/framework-workspace.md +7 -0
  71. package/injects/instance-boundary.md +43 -0
  72. package/injects/local-soul.md +19 -0
  73. package/injects/oats.md +11 -0
  74. package/injects/work-attached.md +16 -0
  75. package/injects/work-checkout.md +12 -0
  76. package/injects/work-workspace.md +22 -0
  77. package/injects/work-worktree.md +13 -0
  78. package/lib/core.mjs +6792 -0
  79. package/lib/packages.mjs +1375 -0
  80. package/lib/tmux-config.mjs +35 -0
  81. package/package-catalog.json +44 -0
  82. package/package.json +55 -0
  83. package/packages/record/README.md +151 -0
  84. package/packages/record/bin/capture.mjs +256 -0
  85. package/packages/record/bin/recall.mjs +102 -0
  86. package/packages/record/bin/setup.mjs +281 -0
  87. package/packages/record/bin/turn-record.mjs +47 -0
  88. package/packages/record/docs/turn-record-sot.md +350 -0
  89. package/packages/record/lib/canonical.mjs +151 -0
  90. package/packages/record/lib/capture-aw.mjs +179 -0
  91. package/packages/record/lib/capture-cc.mjs +322 -0
  92. package/packages/record/lib/formats.mjs +296 -0
  93. package/packages/record/lib/ignore.mjs +155 -0
  94. package/packages/record/lib/index-db.mjs +524 -0
  95. package/packages/record/lib/project-aweb.mjs +251 -0
  96. package/packages/record/lib/segments.mjs +186 -0
  97. package/packages/record/lib/store.mjs +447 -0
  98. package/packages/record/lib/tags.mjs +114 -0
  99. package/packages/record/package.json +45 -0
  100. package/packages/record/test/vectors/README.md +34 -0
  101. package/packages/record/test/vectors/aweb-projection-v1.json +231 -0
  102. package/packages/record/test/vectors/journal-merge-v1.json +261 -0
  103. package/packages/record/test/vectors/turn-id-v1.json +152 -0
  104. package/packages/record/test/vectors/validate.mjs +391 -0
  105. package/skills/integration-authoring/SKILL.md +76 -0
  106. package/skills/oats/SKILL.md +151 -0
  107. package/skills/oats-config/SKILL.md +162 -0
  108. package/skills/oats-getting-started/SKILL.md +159 -0
  109. package/skills/oats-packages/SKILL.md +180 -0
  110. package/skills/oats-support/SKILL.md +79 -0
  111. package/skills/skill-craft/SKILL.md +109 -0
  112. package/skills/soul-craft/SKILL.md +109 -0
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env node
2
+ // turn-record setup — make this machine capture for real.
3
+ //
4
+ // Installs, idempotently:
5
+ // 1. Stop + SessionEnd capture hooks into every ~/.claude*/settings.json
6
+ // that exists (merged, never clobbered; unparseable files are skipped
7
+ // loudly and left untouched);
8
+ // 2. a background watcher service — launchd agent on macOS, systemd user
9
+ // unit on Linux (printed instructions elsewhere);
10
+ // 3. then runs the first capture pass (the expensive one) unless --dry-run.
11
+ //
12
+ // Everything is derived from the running install: node = process.execPath,
13
+ // scripts resolved relative to this file, so it works from a global npm
14
+ // install, npx, or a repo checkout alike.
15
+ //
16
+ // --owner <name> stream owner (default: LocalHostName on macOS, hostname)
17
+ // --no-hooks skip step 1
18
+ // --no-service skip step 2
19
+ // --dry-run print what would change, change nothing
20
+
21
+ import { execFileSync, spawnSync } from "node:child_process";
22
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
23
+ import { homedir, hostname, platform } from "node:os";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ // Config files deserve the same crash safety as the store's objects:
28
+ // write-then-rename, never a partial write in place.
29
+ function writeFileAtomic(path, content) {
30
+ const tmp = path + ".tmp-" + process.pid;
31
+ writeFileSync(tmp, content);
32
+ renameSync(tmp, path);
33
+ }
34
+
35
+ const HERE = dirname(fileURLToPath(import.meta.url));
36
+ const NODE = process.execPath;
37
+ const CAPTURE = join(HERE, "capture.mjs");
38
+
39
+ function parseArgs(argv) {
40
+ const args = {};
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const a = argv[i];
43
+ if (a === "--owner") args.owner = argv[++i];
44
+ else if (a.startsWith("--")) args[a.slice(2)] = true;
45
+ else {
46
+ console.error(`unknown argument ${a}`);
47
+ process.exit(2);
48
+ }
49
+ }
50
+ return args;
51
+ }
52
+
53
+ function defaultOwner() {
54
+ if (platform() === "darwin") {
55
+ try {
56
+ const name = execFileSync("scutil", ["--get", "LocalHostName"]).toString().trim();
57
+ if (name) return name;
58
+ } catch {
59
+ /* fall through to hostname */
60
+ }
61
+ }
62
+ return hostname().split(".")[0];
63
+ }
64
+
65
+ const args = parseArgs(process.argv.slice(2));
66
+ const owner = args.owner ?? defaultOwner();
67
+ const dry = Boolean(args["dry-run"]);
68
+
69
+ // The owner is interpolated into a persisted shell command, a launchd
70
+ // plist, and a systemd unit. Restricting it to the stream-id character
71
+ // class (which the store requires anyway) makes it shell-, XML-, and
72
+ // INI-safe in one check, BEFORE anything is written.
73
+ if (!/^[A-Za-z0-9._-]+$/.test(owner)) {
74
+ console.error(
75
+ `invalid --owner ${JSON.stringify(owner)}: letters, digits, dot, underscore, hyphen only`,
76
+ );
77
+ process.exit(2);
78
+ }
79
+
80
+ const hookCommand = `${NODE} ${CAPTURE} --owner ${owner} --quiet`;
81
+ const HOOK = { type: "command", command: hookCommand, async: true, timeout: 120 };
82
+
83
+ console.log(`owner: ${owner}`);
84
+ console.log(`node: ${NODE}`);
85
+
86
+ // ------------------------------------------------------------------- hooks
87
+
88
+ function settingsFiles() {
89
+ const home = homedir();
90
+ const files = [];
91
+ for (const name of readdirSync(home).sort()) {
92
+ if (!name.startsWith(".claude")) continue;
93
+ const path = join(home, name, "settings.json");
94
+ if (existsSync(path)) files.push(path);
95
+ }
96
+ return files;
97
+ }
98
+
99
+ function installHooks() {
100
+ for (const path of settingsFiles()) {
101
+ let settings;
102
+ try {
103
+ settings = JSON.parse(readFileSync(path, "utf8"));
104
+ } catch (err) {
105
+ console.error(`SKIP ${path}: not valid JSON (${err.message}) — fix it and re-run setup`);
106
+ continue;
107
+ }
108
+ if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
109
+ console.error(`SKIP ${path}: not a JSON object`);
110
+ continue;
111
+ }
112
+ let changed = false;
113
+ settings.hooks = settings.hooks ?? {};
114
+ // Ours = a command whose STRUCTURE matches what setup generates:
115
+ // "<node> <...>/capture.mjs ... --owner ... --quiet ...". Matching by
116
+ // shape rather than this install's absolute path means a moved install
117
+ // (npm upgrade, checkout -> global) updates the old hook in place
118
+ // instead of stacking a second one. The structural requirements (script
119
+ // is the second token and ends in /capture.mjs; both flags present as
120
+ // whole tokens) keep it from firing on unrelated hooks that merely
121
+ // mention similar words; when in doubt it errs toward appending a new
122
+ // group, never toward overwriting someone else's command.
123
+ const ours = (h) => {
124
+ if (h?.type !== "command") return false;
125
+ const tokens = String(h.command).trim().split(/\s+/);
126
+ return (
127
+ tokens.length >= 2 &&
128
+ tokens[1].endsWith("/capture.mjs") &&
129
+ tokens.includes("--owner") &&
130
+ tokens.includes("--quiet")
131
+ );
132
+ };
133
+ for (const event of ["Stop", "SessionEnd"]) {
134
+ const groups = (settings.hooks[event] = settings.hooks[event] ?? []);
135
+ let found = false;
136
+ for (const group of groups) {
137
+ for (const hook of group.hooks ?? []) {
138
+ if (!ours(hook)) continue;
139
+ found = true;
140
+ if (hook.command !== hookCommand) {
141
+ hook.command = hookCommand;
142
+ changed = true;
143
+ }
144
+ }
145
+ }
146
+ if (!found) {
147
+ groups.push({ hooks: [{ ...HOOK }] });
148
+ changed = true;
149
+ }
150
+ }
151
+ if (!changed) {
152
+ console.log(`hooks: ${path} already installed`);
153
+ } else if (dry) {
154
+ console.log(`hooks: would install Stop+SessionEnd capture in ${path}`);
155
+ } else {
156
+ writeFileAtomic(path, JSON.stringify(settings, null, 2) + "\n");
157
+ console.log(`hooks: installed Stop+SessionEnd capture in ${path}`);
158
+ }
159
+ }
160
+ }
161
+
162
+ // ----------------------------------------------------------------- service
163
+
164
+ function installServiceDarwin() {
165
+ const label = "ai.aweb.turn-record-capture";
166
+ const path = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
167
+ const logPath = join(homedir(), "Library", "Logs", "turn-record-capture.log");
168
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
169
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
170
+ <plist version="1.0">
171
+ <dict>
172
+ <key>Label</key>
173
+ <string>${label}</string>
174
+ <key>ProgramArguments</key>
175
+ <array>
176
+ <string>${NODE}</string>
177
+ <string>${CAPTURE}</string>
178
+ <string>--watch</string>
179
+ <string>--owner</string>
180
+ <string>${owner}</string>
181
+ </array>
182
+ <key>RunAtLoad</key>
183
+ <true/>
184
+ <key>KeepAlive</key>
185
+ <true/>
186
+ <key>ThrottleInterval</key>
187
+ <integer>60</integer>
188
+ <key>StandardOutPath</key>
189
+ <string>${logPath}</string>
190
+ <key>StandardErrorPath</key>
191
+ <string>${logPath}</string>
192
+ </dict>
193
+ </plist>
194
+ `;
195
+ const current = existsSync(path) ? readFileSync(path, "utf8") : null;
196
+ if (current === plist) {
197
+ console.log(`service: ${path} already installed`);
198
+ return;
199
+ }
200
+ if (dry) {
201
+ console.log(`service: would ${current ? "update" : "install"} launchd agent at ${path}`);
202
+ return;
203
+ }
204
+ mkdirSync(dirname(path), { recursive: true });
205
+ writeFileAtomic(path, plist);
206
+ const uid = process.getuid();
207
+ spawnSync("launchctl", ["bootout", `gui/${uid}/${label}`], { stdio: "ignore" });
208
+ const boot = spawnSync("launchctl", ["bootstrap", `gui/${uid}`, path], { encoding: "utf8" });
209
+ if (boot.status === 0) {
210
+ console.log(`service: launchd agent ${label} running (log: ${logPath})`);
211
+ } else {
212
+ console.error(
213
+ `service: wrote ${path} but launchctl bootstrap failed (${(boot.stderr || "").trim()}); ` +
214
+ `load it manually: launchctl bootstrap gui/${uid} ${path}`,
215
+ );
216
+ }
217
+ }
218
+
219
+ function installServiceLinux() {
220
+ const path = join(homedir(), ".config", "systemd", "user", "turn-record-capture.service");
221
+ const unit = `[Unit]
222
+ Description=turn-record capture watcher
223
+
224
+ [Service]
225
+ ExecStart=${NODE} ${CAPTURE} --watch --owner ${owner}
226
+ Restart=always
227
+ RestartSec=60
228
+
229
+ [Install]
230
+ WantedBy=default.target
231
+ `;
232
+ const current = existsSync(path) ? readFileSync(path, "utf8") : null;
233
+ if (current === unit) {
234
+ console.log(`service: ${path} already installed`);
235
+ return;
236
+ }
237
+ if (dry) {
238
+ console.log(`service: would ${current ? "update" : "install"} systemd user unit at ${path}`);
239
+ return;
240
+ }
241
+ mkdirSync(dirname(path), { recursive: true });
242
+ writeFileAtomic(path, unit);
243
+ const enable = spawnSync(
244
+ "systemctl",
245
+ ["--user", "enable", "--now", "turn-record-capture.service"],
246
+ { encoding: "utf8" },
247
+ );
248
+ if (enable.status === 0) {
249
+ console.log("service: systemd user unit turn-record-capture running");
250
+ } else {
251
+ console.error(
252
+ `service: wrote ${path} but systemctl enable failed (${(enable.stderr || "").trim()}); ` +
253
+ "enable it manually: systemctl --user enable --now turn-record-capture.service",
254
+ );
255
+ }
256
+ }
257
+
258
+ function installService() {
259
+ const os = platform();
260
+ if (os === "darwin") installServiceDarwin();
261
+ else if (os === "linux") installServiceLinux();
262
+ else {
263
+ console.log(
264
+ `service: no service template for ${os}; run this in the background yourself:\n` +
265
+ ` ${NODE} ${CAPTURE} --watch --owner ${owner}`,
266
+ );
267
+ }
268
+ }
269
+
270
+ // --------------------------------------------------------------------- run
271
+
272
+ if (!args["no-hooks"]) installHooks();
273
+ if (!args["no-service"]) installService();
274
+
275
+ if (dry) {
276
+ console.log("dry run: no first capture pass");
277
+ } else {
278
+ console.log("running first capture pass (the initial one can take minutes)...");
279
+ const pass = spawnSync(NODE, [CAPTURE, "--owner", owner], { stdio: "inherit" });
280
+ process.exit(pass.status ?? 1);
281
+ }
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // turn-record — single shipped entry point for the record core.
3
+ //
4
+ // turn-record capture [...] land sessions and aw logs in the record
5
+ // turn-record recall [...] search the record
6
+ // turn-record setup [...] install hooks + background watcher, run first pass
7
+ //
8
+ // The experimental tools over the record (dress, spawn, segments, mind)
9
+ // live in packages/experimental of the oats repo and run as
10
+ // `oats experimental <cmd>`; they are not part of this package.
11
+ //
12
+ // The subcommand scripts read process.argv.slice(2), so the subcommand name
13
+ // is removed before importing them.
14
+
15
+ const EXPERIMENTAL = new Set(["dress", "segments", "spawn", "mind"]);
16
+ const sub = process.argv[2];
17
+ const rest = () => process.argv.splice(2, 1);
18
+
19
+ switch (sub) {
20
+ case "capture":
21
+ rest();
22
+ await import("./capture.mjs");
23
+ break;
24
+ case "recall":
25
+ rest();
26
+ await import("./recall.mjs");
27
+ break;
28
+ case "setup":
29
+ rest();
30
+ await import("./setup.mjs");
31
+ break;
32
+ default:
33
+ if (EXPERIMENTAL.has(sub)) {
34
+ console.error(
35
+ `turn-record: "${sub}" is an experimental tool and moved out of this package.\n` +
36
+ `Run it from an oats repo checkout: oats experimental ${sub} [options]`,
37
+ );
38
+ process.exit(2);
39
+ }
40
+ console.error(
41
+ "usage: turn-record <capture|recall|setup> [options]\n" +
42
+ " capture [--watch|--status|--owner <name>|--root <dir>]\n" +
43
+ " recall [--kind k] [--thread t] [--from f] [--show id] <query>\n" +
44
+ " setup [--owner <name>] [--no-service] [--no-hooks] [--dry-run]",
45
+ );
46
+ process.exit(sub === undefined || sub === "--help" || sub === "-h" ? 0 : 2);
47
+ }
@@ -0,0 +1,350 @@
1
+ # Turn record — Source of Truth
2
+
3
+ Status: **v1 draft — proposed normative contract, not yet shipped behavior**.
4
+
5
+ This document specifies `turn.jsonl` v1: the format, store layout, and
6
+ synchronization contract for the turn record, and the normative projection of
7
+ aweb mail and chat messages into it. It is the contract implemented by the
8
+ record tools (`capture`, `recall`, and the experimental tools over them); this
9
+ package (`@awebai/turn-record`, in the oats repo) is the reference
10
+ implementation, and the spec lives beside it on purpose: the record spans
11
+ agent sessions from every harness, and the aweb server is one projected
12
+ source, not the record's home. The architecture decision it implements is
13
+ `2026-08-18-turn-record-and-tools.md` in the strategy repo
14
+ (github.com/awebai/strategy); the identity and messaging contracts it builds
15
+ on are `docs/awid-sot.md` and `docs/aweb-sot.md` in the aweb repo
16
+ (github.com/awebai/aweb), which remain authoritative for signing and
17
+ message semantics.
18
+
19
+ Conformance vectors live in [`test/vectors/`](../test/vectors/) with a
20
+ dependency-free validator (`node validate.mjs`). A behavior is part of this
21
+ contract only if a vector pins it or this document states it normatively.
22
+
23
+ ---
24
+
25
+ ## Principles
26
+
27
+ 1. **One sacred artifact.** The turn format and its sync contract are the only
28
+ things every tool must agree on. Everything else — indexes, caches,
29
+ projections, conventions — is derived and rebuildable.
30
+ 2. **Turns are immutable and content-addressed.** A turn's identity is the hash
31
+ of its canonical bytes. There is no update operation; correction and
32
+ deletion are new turns (tombstones).
33
+ 3. **Writers own streams.** Every turn is appended to exactly one journal, and
34
+ only that journal's owner ever appends to it. This makes replication
35
+ conflict-free by construction, not by resolution.
36
+ 4. **Sync is set union.** Replicas merge by unioning streams and deduplicating
37
+ by turn id. Global order does not exist and is not needed; causal order
38
+ comes from `thread` and `links`.
39
+ 5. **Fidelity is declared, never implied.** A projected turn says what its
40
+ source was and what loss class the projection has.
41
+ 6. **Signatures travel with the data.** Turns carry the original source
42
+ signatures verbatim where they exist, and may carry a producer envelope
43
+ signature. Verification happens at read time; transport is untrusted.
44
+ 7. **Mutable state never enters a turn.** Delivery state (read, acked),
45
+ presence, and other receiver-local facts are not part of any turn's
46
+ canonical core — they would make identical content hash differently on
47
+ different machines.
48
+
49
+ ---
50
+
51
+ ## Canonical JSON
52
+
53
+ Turn hashing and signing reuse the existing cross-language canonicalization
54
+ contract from awid message signing (`awid/src/awid/signing.py`
55
+ `canonical_json_bytes`, Go `CanonicalJSONValue`):
56
+
57
+ - UTF-8 bytes of the JSON serialization;
58
+ - object keys sorted lexicographically by Unicode code point;
59
+ - separators `,` and `:` with no whitespace;
60
+ - `ensure_ascii=false` — non-ASCII characters are emitted as themselves;
61
+ - **no HTML escaping** — `<`, `>`, `&` are emitted literally (the known Go
62
+ `json.Marshal` trap; see the comment on `CanonicalJSONValue`).
63
+
64
+ **Number restriction (normative):** inside a turn's canonical core, numbers
65
+ MUST be integers with absolute value below 2^53. Fractional and exponent
66
+ literals are forbidden in the core because float serialization is not
67
+ canonical across languages. Anything non-integral MUST be carried as a string
68
+ or moved to a content-addressed object.
69
+
70
+ ## The turn
71
+
72
+ One turn is one JSON object. On disk it is one line of a journal file.
73
+
74
+ | Field | Type | Required | Meaning |
75
+ |---|---|---|---|
76
+ | `v` | int | yes | format version; this document defines `1` |
77
+ | `id` | string | yes on disk | content address: `t1:<sha256 hex of canonical core>` |
78
+ | `ts` | string | yes | RFC 3339 UTC (`Z` suffix); fractional seconds permitted; projections preserve source precision |
79
+ | `from` | string | yes | producing/speaking name: address, alias, `did:key`, or `did:aw` |
80
+ | `to` | string \| string[] | no | addressed recipients, same name forms |
81
+ | `thread` | string | no | conversation reference, namespaced (see [Threads](#threads)) |
82
+ | `kind` | string | yes | source class (see [Kinds](#kinds)) |
83
+ | `body` | object | yes | content; may be `{}`; large/binary content by reference: `{"ref":"sha256:<hex>","media_type":...,"bytes":<int>}` |
84
+ | `links` | array | no | `{"rel": <string>, "ref": <turn id or "sha256:<hex>">}` entries |
85
+ | `signature` | string | no | source signature, verbatim (e.g. aweb message signature) |
86
+ | `signed_payload` | string | no | source canonical signed payload, **byte-verbatim** |
87
+ | `provenance` | object | yes | see [Provenance](#provenance) |
88
+ | `sig` | object | no | producer envelope signature: `{"by":"did:key:...","sig":"<base64>"}` |
89
+
90
+ **Canonical core** = the turn object minus `id` and `sig`, serialized as
91
+ canonical JSON.
92
+
93
+ - `id` = `"t1:" + sha256hex(canonical core bytes)`. Lowercase hex.
94
+ - `sig.sig` = Ed25519 signature over the same canonical core bytes, base64
95
+ (RFC 4648) without padding; verifiers accept both the standard and the
96
+ URL-safe alphabet, exactly as awid message verification does.
97
+ - `sig.by` is a `did:key`; verification resolves the public key from the DID
98
+ as in awid.
99
+
100
+ Because `id` excludes `sig`, co-signing does not change identity: the same
101
+ content is the same turn whether or not its producer signed the envelope.
102
+ When id-equal duplicates differ in `sig`, replicas prefer a copy with a valid
103
+ `sig` over one without; further attestation is expressed as separate turns,
104
+ not by mutating this one.
105
+
106
+ **Determinism (normative):** a projection MUST be a pure function of its
107
+ source data. Two machines projecting the same source row MUST produce
108
+ byte-identical canonical cores, hence the same `id`, so union dedupes them.
109
+ This is why nothing machine-local (paths, host names, wall-clock at projection
110
+ time) may appear in a projected turn's core.
111
+
112
+ **Unknown fields are preserved.** Readers and re-serializers MUST carry
113
+ unknown top-level and nested fields through untouched (they are covered by
114
+ `id`). Validators reject a turn only when its `id` or signatures fail, never
115
+ because a field is unrecognized. Lines whose `v` is greater than 1 are
116
+ preserved verbatim and not interpreted.
117
+
118
+ ### Kinds
119
+
120
+ v1 defines: `mail`, `chat`, `session`, `note`, `tombstone`.
121
+ Reserved for later specification: `attestation`, `lesson`.
122
+
123
+ **`session` turns are one-per-native-event** (corrected 2026-08-19, Juan:
124
+ sessions are turns like everything else, never opaque file snapshots).
125
+ Each native transcript record — a Claude Code JSONL line, a pi record, a
126
+ Codex record — is one turn:
127
+
128
+ kind: "session"
129
+ thread: "<source>:session:<id>"
130
+ body: { line: "<the native record, verbatim text>" }
131
+ ts: the event's own timestamp (carried forward over unstamped
132
+ bookkeeping lines)
133
+ provenance: { source: cc|pi|codex, fidelity: "verbatim",
134
+ origin: { session_id, line: <line number> } }
135
+
136
+ The body is the exact bytes of the native line, so the original transcript
137
+ is reconstructible by concatenation, nothing is interpreted at storage
138
+ time, and capture appends each event exactly once as the session grows —
139
+ storage is linear in conversation size by construction. Session turns live
140
+ in one stream per session (`<owner>~<source>.<session-id>`), keeping
141
+ journals bounded by their conversation. An earlier whole-file-snapshot
142
+ encoding (content-addressed blobs referenced by meta-turns) is withdrawn:
143
+ it duplicated every prefix on every snapshot and put a file, not the turn,
144
+ at the center of the model.
145
+
146
+ ### Threads
147
+
148
+ `thread` is a namespaced string: `<namespace>:<type>:<id>`.
149
+
150
+ - aweb conversations: `aweb:conv:<conversation_id>` (mail) and
151
+ `aweb:conv:<session_id>` (chat) — both are UUIDs in the same server
152
+ namespace.
153
+ - Captured runtime sessions: `cc:session:<session_id>` for Claude Code;
154
+ other runtimes register their own namespace.
155
+
156
+ ### Provenance
157
+
158
+ ```json
159
+ {
160
+ "source": "aweb-mail" | "aweb-chat" | "cc" | ...,
161
+ "fidelity": "verbatim" | "projected" | "summary",
162
+ "origin": { ...source-native identifiers... },
163
+ "runtime": { "credential": "grant:<id>", ... }
164
+ }
165
+ ```
166
+
167
+ - `source` and `fidelity` are required.
168
+ - **Fidelity classes:**
169
+ - `verbatim` — the source bytes are preserved (inline or in the object
170
+ store); nothing was interpreted.
171
+ - `projected` — a structured, lossless field mapping; the round-trip back to
172
+ the source fields is guaranteed and vector-tested.
173
+ - `summary` — lossy by design; the reader is told so.
174
+ - `origin` carries source-native identifiers needed for round-trip that are
175
+ not already inside `signed_payload`. **Do not duplicate** into `origin` what
176
+ is derivable from `signed_payload`; duplication invites divergence.
177
+ - `runtime` names the acting credential when a runtime produced the turn
178
+ (grant attribution; see the aweb server provenance work).
179
+
180
+ ## Store layout
181
+
182
+ ```
183
+ <record-root>/
184
+ streams/<stream-id>/journal.jsonl replicated, append-only, owner-write
185
+ objects/sha256/<hh>/<hex> replicated, immutable, content-addressed
186
+ index/ derived, rebuildable, never replicated
187
+ ```
188
+
189
+ - **stream-id** is a filesystem-safe name unique to one writer:
190
+ `<owner>~<source>`, e.g. `did-aw-1abc...~mail`, `mac-a~cc`. The owner is the
191
+ only party that ever appends to it. `~` is the separator because it cannot
192
+ appear in aweb aliases or DID identifiers. Sources that are naturally
193
+ per-life suffix the stream with the life's id, same shape: captured
194
+ sessions as `<owner>~<source>.<session-id>`, and reader judgments as
195
+ `<owner>~mind.<principal>` — one stream per followed life, written by
196
+ that life's jiminy (`from: <jiminy-name>`).
197
+ - **journal.jsonl**: UTF-8, one JSON turn per line, each line terminated by
198
+ `\n`. A truncated final line (missing newline or invalid JSON) is ignored by
199
+ readers and repaired by the owner on its next append — this is the crash
200
+ tolerance contract.
201
+ - **objects/** holds immutable blobs keyed by SHA-256 (two-hex-char fan-out).
202
+ Turns reference them as `sha256:<hex>`.
203
+ - **index/** (SQLite metadata/FTS, later vectors) is cache. Deleting it loses
204
+ nothing; any replica can rebuild it from streams + objects.
205
+
206
+ ## Synchronization contract
207
+
208
+ Any file-level replication mechanism (syncthing, rsync, git, an object relay)
209
+ is a valid transport, because the invariants below make merges mechanical.
210
+
211
+ 1. **Owner-only append.** A replica never writes into a stream it does not
212
+ own. Sync tools replicate `streams/` and `objects/`, never `index/`.
213
+ 2. **Stream merge is prefix extension.** Two copies of the same journal must
214
+ be related by prefix (append-only). Merged result = the longer copy. If
215
+ neither is a prefix of the other, that is corruption or an ownership
216
+ violation: quarantine both copies and surface an error; never merge
217
+ silently, never last-writer-wins.
218
+ 3. **Record merge is set union.** The effective record = union of turns across
219
+ all streams, deduplicated by `id`. Duplicate ids across streams are normal
220
+ (deterministic projections) and harmless.
221
+ 4. **Tombstones dominate.** A turn is hidden from readers when a valid
222
+ tombstone targets it (below), regardless of which replica or stream the
223
+ tombstone arrived from.
224
+ 5. **Verification at read time.** `signed_payload`/`signature` and `sig` are
225
+ verified by consumers, per awid rules. Transport integrity is not assumed.
226
+
227
+ ### Tombstones
228
+
229
+ A tombstone is itself a turn:
230
+
231
+ ```json
232
+ {"v":1, "kind":"tombstone", "from":..., "ts":...,
233
+ "links":[{"rel":"tombstones","ref":"t1:<target id>"}],
234
+ "body":{"reason": "..."}, "provenance":{"source":..., "fidelity":"projected"}}
235
+ ```
236
+
237
+ v1 authority rule (deliberately minimal): a tombstone is valid when its
238
+ `from` equals the target turn's `from`, or equals the record owner's identity
239
+ as configured for the store. Richer delegation is a later revision.
240
+
241
+ Effects: readers MUST hide the target turn and MUST exclude it from tool
242
+ output; indexes drop it on rebuild. The owner of the target's stream MAY
243
+ physically remove the body by compacting **its own** journal (rewriting its
244
+ own stream is permitted to the owner alone; other replicas converge by prefix
245
+ rule on the new journal only if the owner bumps the stream — in v1, owners
246
+ compact by writing a new stream `<stream-id>.<n>` and retiring the old one).
247
+
248
+ **Honest caveat (normative text, carried from the architecture decision):** an
249
+ offline replica physically retains bytes until it reconnects and receives the
250
+ tombstone. Deletion is eventual, and tools must not claim otherwise.
251
+
252
+ ## Projection of aweb messages
253
+
254
+ This is the projection the conformance vectors prove. It covers the message
255
+ content contract: the fields listed below. Receiver-local delivery state
256
+ (read/acked flags, folder placement) is out of scope by principle 7.
257
+
258
+ ### Signed mail
259
+
260
+ Input: a message row with `message_id`, `conversation_id`, `signature`,
261
+ `signed_payload` (canonical JSON string per awid `SIGNED_FIELDS`), and the
262
+ transport-echoed content fields.
263
+
264
+ ```json
265
+ {
266
+ "v": 1,
267
+ "ts": "<signed_payload.timestamp>",
268
+ "from": "<signed_payload.from>",
269
+ "to": "<signed_payload.to>",
270
+ "thread": "aweb:conv:<conversation_id>",
271
+ "kind": "mail",
272
+ "body": {"subject": "<signed_payload.subject>", "text": "<signed_payload.body>"},
273
+ "signature": "<row.signature verbatim>",
274
+ "signed_payload": "<row.signed_payload byte-verbatim>",
275
+ "provenance": {
276
+ "source": "aweb-mail",
277
+ "fidelity": "projected",
278
+ "origin": {"message_id": "<uuid>", "conversation_id": "<uuid>"}
279
+ }
280
+ }
281
+ ```
282
+
283
+ `id` is then computed over the canonical core as usual.
284
+
285
+ **Consistency rule (normative):** for a turn carrying `signed_payload`, the
286
+ turn's `ts`, `from`, `to`, `body.subject`, and `body.text` MUST equal the
287
+ corresponding `signed_payload` fields (`timestamp`, `from`, `to`, `subject`,
288
+ `body`). Validators reject on mismatch. The duplication exists only for
289
+ readability and indexing; `signed_payload` is authoritative.
290
+
291
+ **Round-trip (normative):** `unproject(project(row)) == row` for the field
292
+ set: `message_id`, `conversation_id`, `signature`, `signed_payload` (both
293
+ byte-identical), plus every field parsed from `signed_payload` (`from`,
294
+ `from_did`, `from_stable_id?`, `to`, `to_did`, `to_stable_id?`, `subject`,
295
+ `body`, `timestamp`, `message_id`, `conversation_id?`, `priority?`,
296
+ `reply_to?`, `wait_seconds?`, `hang_on?`, `sender_leaving?`) value-identical.
297
+ Verification status is derived at read time exactly as
298
+ `server/src/aweb/messaging/verification.py` does; it is never stored.
299
+
300
+ ### Signed chat
301
+
302
+ Identical, with `kind: "chat"`, `provenance.source: "aweb-chat"`, and
303
+ `thread: "aweb:conv:<session_id>"`. Chat-only signed fields (`wait_seconds`,
304
+ `hang_on`, `sender_leaving`, `reply_to`) live inside `signed_payload` and are
305
+ recovered from it; they are not duplicated onto the turn.
306
+
307
+ ### Legacy unsigned messages
308
+
309
+ Rows without `signature`/`signed_payload` project with the same shape minus
310
+ those two fields, and `origin` additionally carries the row-level fields that
311
+ signed rows recover from the payload: `from_did?`, `from_stable_id?`,
312
+ `to_did?`, `to_stable_id?`, `priority?`, `timestamp_source: "created_at"` when
313
+ `ts` was taken from the row's `created_at`. Their verification status derives
314
+ to `unverified`, matching server behavior.
315
+
316
+ ## Conformance vectors
317
+
318
+ `test/vectors/` (in this package):
319
+
320
+ | File | Proves |
321
+ |---|---|
322
+ | `turn-id-v1.json` | canonical core bytes and `t1:` ids, including the unicode / HTML-character trap, unknown-field coverage, and envelope `sig` verification |
323
+ | `aweb-projection-v1.json` | the mail/chat/legacy projections above: row → expected turn (byte-exact core, exact id) → round-trip row equality, with real signatures (test seed `000102…1f`, the `docs/vectors/message-signing-v1.json` identity) |
324
+ | `journal-merge-v1.json` | union dedup by id, prefix rule (including the corruption case), tombstone dominance and the v1 authority rule |
325
+ | negatives (in each file) | id mismatch, invalid signature, signed-consistency violation, float in core |
326
+
327
+ `validate.mjs` (Node ≥ 18, no dependencies) re-derives everything
328
+ independently of the Python generator, so passing vectors is a two-language
329
+ agreement, the same discipline as `e2ee-v2-cross-language.json`. Run:
330
+
331
+ ```bash
332
+ node test/vectors/validate.mjs
333
+ ```
334
+
335
+ ## Versioning
336
+
337
+ - `v` bumps only for changes that alter canonical bytes, id computation, or
338
+ merge semantics. Additive fields do not bump `v` (unknown-field preservation
339
+ covers them).
340
+ - A store may hold mixed versions; tools interpret the versions they know and
341
+ preserve the rest.
342
+
343
+ ## Deliberately deferred
344
+
345
+ - Tombstone delegation beyond the v1 owner/author rule.
346
+ - Encrypted turn bodies (`encrypted_v2` envelopes project today with body by
347
+ reference and fidelity declared; a first-class ciphertext mode is a later
348
+ revision).
349
+ - Entitlement-scoped partial replicas (the relay concern, not the format's).
350
+ - The `session` body schema (specified with `capture`, M1).