deepdebug-local-agent 1.7.0 → 1.9.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 (71) hide show
  1. package/.claude/context.md +2 -1
  2. package/CLAUDE.md +1 -1
  3. package/docs/la-protocol-v2.md +27 -4
  4. package/env.example +12 -0
  5. package/package.json +1 -1
  6. package/scripts/incident-parity/Gen.java +256 -0
  7. package/scripts/incident-parity/LogEntry.java +17 -0
  8. package/scripts/incident-parity/extract.py +51 -0
  9. package/scripts/incident-parity/gen.sh +32 -0
  10. package/src/incident-detector/README.md +52 -0
  11. package/src/incident-detector/aggregator.js +78 -0
  12. package/src/incident-detector/categorize.js +48 -0
  13. package/src/incident-detector/config.js +95 -0
  14. package/src/incident-detector/detector.js +93 -0
  15. package/src/incident-detector/emitter.js +164 -0
  16. package/src/incident-detector/index.js +44 -0
  17. package/src/incident-detector/java-compat.js +66 -0
  18. package/src/incident-detector/normalize.js +209 -0
  19. package/src/incident-detector/parse.js +114 -0
  20. package/src/incident-detector/sources/file-source.js +113 -0
  21. package/src/incident-detector/sources/loki-source.js +101 -0
  22. package/src/incident-detector/sources/stream-source.js +39 -0
  23. package/src/incident-detector/sources/webhook-source.js +109 -0
  24. package/src/incident-detector/store.js +119 -0
  25. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +24 -16
  26. package/src/la-protocol-v2/contract/README.md +103 -3
  27. package/src/la-protocol-v2/contract/config.update.schema.json +85 -1
  28. package/src/la-protocol-v2/contract/hello.schema.json +26 -1
  29. package/src/la-protocol-v2/contract/incident.candidate.schema.json +11 -0
  30. package/src/la-protocol-v2/contract/index.json +9 -2
  31. package/src/la-protocol-v2/contract/ready.schema.json +8 -0
  32. package/src/la-protocol-v2/contract/register.schema.json +26 -1
  33. package/src/la-protocol-v2/contract/webhook.forward.schema.json +72 -3
  34. package/src/la-protocol-v2/contract.js +45 -7
  35. package/src/la-protocol-v2/llm-key/config.js +29 -0
  36. package/src/la-protocol-v2/llm-key/handler.js +33 -0
  37. package/src/la-protocol-v2/llm-key/install.js +21 -0
  38. package/src/la-protocol-v2/llm-key/store.js +173 -0
  39. package/src/la-protocol-v2/runtime.js +44 -1
  40. package/src/la-protocol-v2/session.js +47 -13
  41. package/src/la-protocol-v2/webhooks/assembler.js +85 -0
  42. package/src/la-protocol-v2/webhooks/config.js +51 -0
  43. package/src/la-protocol-v2/webhooks/handler.js +125 -0
  44. package/src/la-protocol-v2/webhooks/install.js +22 -0
  45. package/test/contract/la-protocol-v2/vectors/ack.vectors.json +91 -0
  46. package/test/contract/la-protocol-v2/vectors/config.update.vectors.json +1339 -0
  47. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +261 -0
  48. package/test/contract/la-protocol-v2/vectors/incident.candidate.vectors.json +623 -0
  49. package/test/contract/la-protocol-v2/vectors/ready.vectors.json +599 -0
  50. package/test/contract/la-protocol-v2/vectors/register.vectors.json +275 -0
  51. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-after-last.json +186 -0
  52. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-body-hash-changes.json +186 -0
  53. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-last-flag-wrong.json +186 -0
  54. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-non-last-too-short.json +152 -0
  55. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-out-of-order.json +152 -0
  56. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-sha-mismatch.json +186 -0
  57. package/test/contract/la-protocol-v2/vectors/traces/invalid-webhook-chunk-total-changes.json +186 -0
  58. package/test/contract/la-protocol-v2/vectors/traces/valid-webhook-chunks-in-order.json +218 -0
  59. package/test/contract/la-protocol-v2/vectors/webhook.forward.vectors.json +1263 -0
  60. package/test/incident-detector/parity.test.js +66 -0
  61. package/test/incident-detector/parity.vectors.json +2619 -0
  62. package/test/incident-detector/size-model.test.js +127 -0
  63. package/test/incident-detector/sources.test.js +179 -0
  64. package/test/incident-detector/units.test.js +430 -0
  65. package/test/incident-detector/wiring.test.js +165 -0
  66. package/test/la-protocol-v2/contract-v21.test.js +107 -0
  67. package/test/la-protocol-v2/contract.test.js +6 -4
  68. package/test/la-protocol-v2/helpers/reference-cp.js +8 -4
  69. package/test/la-protocol-v2/llm-key.test.js +509 -0
  70. package/test/la-protocol-v2/traces.test.js +3 -3
  71. package/test/la-protocol-v2/webhook-receiver.test.js +702 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * webhook-source.js - the incident detector as the LOCAL consumer of the webhooks the control plane strips and forwards (F3-12 consumer).
3
+ *
4
+ * `webhook.forward` hands the (rebuilt, sha256-verified) body here. It is turned into the same entries every other source produces
5
+ * ({severity, message, stacktrace, service, tsMs}) and goes through the SAME pipeline (Java rules, DLP-masked local store, candidates with
6
+ * metadata only): the text of the webhook never leaves this process except into the local store. The ack of the webhook waits for `deliver()`.
7
+ *
8
+ * text/plain lines, joined into records like a log file (EntryAssembler)
9
+ * alertmanager `alerts[]` with status firing: "alertname: summary", severity label critical -> CRITICAL, error/high/major -> ERROR,
10
+ * anything else WARN (the detector ignores it)
11
+ * sentry `data.event` / `event` (+ `data.issue`): "Type: value" and a stack from the exception frames, `level` as severity,
12
+ * project as service
13
+ * other JSON one log-shaped object (message/msg/level/stack... as in parse.js); a body that does not look like one is `unmapped`
14
+ * Only SYNTHETIC payloads in the tests: the real shapes of each tool should be confirmed with captured webhooks before relying on them.
15
+ * Bounded: at most `maxEntries` entries and `maxBytes` of text per webhook. Counters only; never a word of the body in a log.
16
+ */
17
+ import { EntryAssembler, parseRecord, parseTime } from "../parse.js";
18
+
19
+ const str = (v) => (typeof v === "string" && v !== "" ? v : undefined);
20
+
21
+ function fromAlertmanager(o, maxEntries) {
22
+ const out = [];
23
+ for (const a of Array.isArray(o.alerts) ? o.alerts : []) {
24
+ if (out.length >= maxEntries) break;
25
+ if (!a || typeof a !== "object" || (a.status && a.status !== "firing")) continue;
26
+ const labels = a.labels && typeof a.labels === "object" ? a.labels : {};
27
+ const ann = a.annotations && typeof a.annotations === "object" ? a.annotations : {};
28
+ const name = str(labels.alertname) || "alert";
29
+ const text = str(ann.summary) || str(ann.description) || "";
30
+ const sev = String(labels.severity ?? "").toLowerCase();
31
+ out.push({
32
+ severity: sev === "critical" ? "CRITICAL" : ["error", "high", "major", "page"].includes(sev) ? "ERROR" : "WARN",
33
+ message: text ? `${name}: ${text}` : name,
34
+ stacktrace: null,
35
+ service: str(labels.service) || str(labels.job) || str(labels.app) || str(labels.namespace) || null,
36
+ tsMs: parseTime(a.startsAt),
37
+ });
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function sentryStack(exc) {
43
+ const values = exc && Array.isArray(exc.values) ? exc.values : [];
44
+ const v = values[values.length - 1];
45
+ const frames = v && v.stacktrace && Array.isArray(v.stacktrace.frames) ? v.stacktrace.frames : [];
46
+ if (frames.length === 0) return null;
47
+ return frames.slice().reverse().slice(0, 100).map((f) => ` at ${str(f && f.function) || "?"} (${str(f && (f.filename || f.module)) || "?"}:${Number.isFinite(f && f.lineno) ? f.lineno : 0})`).join("\n");
48
+ }
49
+
50
+ function fromSentry(o) {
51
+ const data = o.data && typeof o.data === "object" ? o.data : o;
52
+ const ev = data.event && typeof data.event === "object" ? data.event : data.issue && typeof data.issue === "object" ? data.issue : null;
53
+ if (!ev) return [];
54
+ const exc = ev.exception && typeof ev.exception === "object" ? ev.exception : null;
55
+ const last = exc && Array.isArray(exc.values) ? exc.values[exc.values.length - 1] : null;
56
+ const head = last && str(last.type) ? `${last.type}${str(last.value) ? `: ${last.value}` : ""}` : str(ev.title) || str(ev.message) || str(ev.culprit);
57
+ if (!head) return [];
58
+ const proj = ev.project && typeof ev.project === "object" ? ev.project : null;
59
+ const ts = typeof ev.timestamp === "number" ? ev.timestamp : ev.datetime || ev.firstSeen;
60
+ return [{
61
+ severity: str(ev.level) ? String(ev.level).toUpperCase() : "ERROR",
62
+ message: head.split("\n")[0],
63
+ stacktrace: sentryStack(exc),
64
+ service: str(o.project_name) || str(proj && (proj.slug || proj.name)) || str(ev.project) || null,
65
+ tsMs: parseTime(ts),
66
+ }];
67
+ }
68
+
69
+ /** @returns {{entries:object[], unmapped:boolean}} */
70
+ export function entriesFromWebhook({ source, contentType, body }, { maxEntries = 200, maxBytes = 1048576, maxEntryBytes, maxStackLines } = {}) {
71
+ const text = new TextDecoder("utf-8").decode(body.length > maxBytes ? body.subarray(0, maxBytes) : body);
72
+ if (contentType === "text/plain") {
73
+ const asm = new EntryAssembler({ maxEntryBytes, maxStackLines });
74
+ const out = [];
75
+ for (const line of text.split("\n")) { out.push(...asm.push(line.replace(/\r$/, ""))); if (out.length >= maxEntries) break; }
76
+ out.push(...asm.flush());
77
+ const entries = out.slice(0, maxEntries);
78
+ return { entries, unmapped: entries.length === 0 };
79
+ }
80
+ let o;
81
+ try { o = JSON.parse(text); } catch { return { entries: [], unmapped: true }; }
82
+ if (!o || typeof o !== "object" || Array.isArray(o)) return { entries: [], unmapped: true };
83
+ let entries = [];
84
+ if (source === "alertmanager" || Array.isArray(o.alerts)) entries = fromAlertmanager(o, maxEntries);
85
+ else if (source === "sentry") entries = fromSentry(o);
86
+ else { const r = parseRecord(text); entries = r ? [r] : []; }
87
+ return { entries, unmapped: entries.length === 0 };
88
+ }
89
+
90
+ export function createWebhookSource({ maxEntries = 200, maxBytes = 1048576, maxEntryBytes, maxStackLines, stats = {} } = {}) {
91
+ let consumer = null;
92
+ Object.assign(stats, { received: 0, entries: 0, unmapped: 0, ...stats });
93
+ return {
94
+ name: "webhook",
95
+ stats,
96
+ start(onEntries) { consumer = onEntries; },
97
+ stop() { consumer = null; },
98
+ /** The sink of the webhook receiver. Resolves when every entry went through the detector (its detail queued): the ack waits for it. */
99
+ async deliver(webhook) {
100
+ if (!consumer) { const e = new Error("webhook source is not started"); e.code = "internal"; throw e; }
101
+ stats.received++;
102
+ const { entries, unmapped } = entriesFromWebhook(webhook, { maxEntries, maxBytes, maxEntryBytes, maxStackLines });
103
+ if (unmapped) { stats.unmapped++; return { entries: 0 }; }
104
+ stats.entries += entries.length;
105
+ await consumer(entries);
106
+ return { entries: entries.length };
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * store.js - the local detail store of detected errors (F3-11b). The TEXT of an error (message, stack trace) lives here and
3
+ * nowhere else: the control plane only ever gets metadata.
4
+ *
5
+ * file <storeDir>/incident-detail.jsonl, one JSON object per line, dir 0700 / file 0600, append only
6
+ * rotation at storeMaxBytes the file moves to incident-detail.jsonl.1 (one generation): disk use <= 2 x storeMaxBytes
7
+ * record {v, ts, errorHash, fingerprint, service, severity, windowStart, message, stack, stackBytes, truncated}
8
+ * message/stack are masked by the DLP (when DLP is on) and cut to storeMessageChars/storeStackChars
9
+ * sampling samplesPerWindow records per errorHash and window (the count is in the candidate, not in this file)
10
+ * queue at most storeMaxQueue writes waiting; add() resolves when the record is queued OR dropped (never blocks the caller
11
+ * for more than the queue allows: when full it waits for room, which slows the reading of the sources = backpressure)
12
+ * getDetail (errorHash, {limit}) reads the newest records back: this is the hook for `content.request` kind `incident.detail`
13
+ * (F3-07 handler, NOT wired here: the contract/gateway kind and its ref scheme are not defined yet, see README.md)
14
+ * never logs a path, a message or a stack: counters only. A broken disk disables the store, not the detector.
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { protectEgress } from "../dlp/index.js";
19
+
20
+ const FILE = "incident-detail.jsonl";
21
+ const cut = (s, n) => (typeof s === "string" && s.length > n ? s.slice(0, n) : s);
22
+
23
+ export function createDetailStore({ dir, maxBytes, maxQueue, messageChars, stackChars, samplesPerWindow, env = process.env, now = Date.now }) {
24
+ const file = path.join(dir, FILE);
25
+ const stats = { written: 0, sampledOut: 0, dropped: 0, errors: 0, rotations: 0, redactions: 0, dlpDenied: 0 };
26
+ const samples = new Map(); // `${errorHash}|${windowStart}` -> records written
27
+ let queue = [];
28
+ let size = null;
29
+ let broken = false;
30
+ let writing = null;
31
+ let waiters = [];
32
+
33
+ async function ensure() {
34
+ if (size !== null) return;
35
+ await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
36
+ size = await fs.promises.stat(file).then((s) => s.size, () => 0);
37
+ }
38
+
39
+ async function drain() {
40
+ try {
41
+ await ensure();
42
+ while (queue.length) {
43
+ const batch = queue;
44
+ queue = [];
45
+ const lines = batch.map((r) => JSON.stringify(r) + "\n");
46
+ const bytes = lines.reduce((n, l) => n + Buffer.byteLength(l), 0);
47
+ if (size > 0 && size + bytes > maxBytes) {
48
+ await fs.promises.rename(file, `${file}.1`).catch(() => {});
49
+ size = 0;
50
+ stats.rotations++;
51
+ }
52
+ await fs.promises.appendFile(file, lines.join(""), { mode: 0o600 });
53
+ size += bytes;
54
+ stats.written += batch.length;
55
+ const w = waiters; waiters = []; for (const f of w) f();
56
+ }
57
+ } catch {
58
+ broken = true; // disk full, permissions...: the detector keeps going without a store
59
+ stats.errors++;
60
+ stats.dropped += queue.length;
61
+ queue = [];
62
+ } finally {
63
+ writing = null;
64
+ const w = waiters; waiters = []; for (const f of w) f();
65
+ }
66
+ }
67
+
68
+ const mask = (text) => {
69
+ if (typeof text !== "string" || text === "") return text;
70
+ const r = protectEgress(text, { env, source: "incident-store" });
71
+ if (r.denied) { stats.dlpDenied++; return "[DLP_DENIED]"; }
72
+ if (r.total) stats.redactions += r.total;
73
+ return r.text;
74
+ };
75
+
76
+ return {
77
+ stats,
78
+ get broken() { return broken; },
79
+ get queued() { return queue.length; },
80
+ /** @returns {Promise<boolean>} true = queued. */
81
+ async add({ errorHash, fingerprint, service, severity, windowStart, message, stack }) {
82
+ if (broken) { stats.dropped++; return false; }
83
+ const key = `${errorHash}|${windowStart}`;
84
+ const n = samples.get(key) || 0;
85
+ if (n >= samplesPerWindow) { stats.sampledOut++; return false; }
86
+ if (samples.size > 50_000) samples.clear(); // bounded: a reset only lets a few extra samples through
87
+ samples.set(key, n + 1);
88
+ while (queue.length >= maxQueue && !broken) await new Promise((res) => { waiters.push(res); if (!writing) writing = drain(); });
89
+ if (broken) { stats.dropped++; return false; }
90
+ const m = typeof message === "string" ? message : "";
91
+ const st = typeof stack === "string" ? stack : "";
92
+ queue.push({
93
+ v: 1, ts: new Date(now()).toISOString(), errorHash, fingerprint, service, severity, windowStart,
94
+ message: mask(cut(m, messageChars)), stack: st ? mask(cut(st, stackChars)) : null,
95
+ stackBytes: Buffer.byteLength(st), truncated: m.length > messageChars || st.length > stackChars,
96
+ });
97
+ if (!writing) writing = drain();
98
+ return true;
99
+ },
100
+ /** Newest first. Reads both generations; a bad line is skipped. */
101
+ async getDetail(errorHash, { limit = 5 } = {}) {
102
+ if (writing) await writing;
103
+ const out = [];
104
+ for (const f of [file, `${file}.1`]) {
105
+ let text;
106
+ try { text = await fs.promises.readFile(f, "utf8"); } catch { continue; }
107
+ const lines = text.split("\n");
108
+ for (let i = lines.length - 1; i >= 0 && out.length < limit; i--) {
109
+ if (!lines[i].includes(errorHash)) continue;
110
+ try { const r = JSON.parse(lines[i]); if (r.errorHash === errorHash) out.push(r); } catch { /* skip */ }
111
+ }
112
+ if (out.length >= limit) break;
113
+ }
114
+ return out;
115
+ },
116
+ /** Waits until everything queued is on disk (tests, shutdown). */
117
+ async flush() { if (!writing && queue.length) writing = drain(); if (writing) await writing; },
118
+ };
119
+ }
@@ -2,15 +2,15 @@
2
2
  "repo": "VISIONHAMMER/vh-api-gateway",
3
3
  "path": "docs/contracts/la-protocol-v2",
4
4
  "branch": "release/qa",
5
- "commit": "b9176054337eeabe416117bafd90ce484a71968e",
5
+ "commit": "c0d6b44a89d2d18a2627493403c06b9caa7d1a96",
6
6
  "note": "Copied by scripts/sync-la-protocol-v2-contract.mjs. Do not edit: change the gateway folder and sync again.",
7
7
  "files": {
8
- "README.md": "93fe7d94f212c36d5402a20cd1397e5236012c6bf1f6bb81546a65033703a88f",
8
+ "README.md": "e247945f859f777a705c5baebecf2a216462a7c2fe1c30598099c96c88cd432b",
9
9
  "ack.schema.json": "d6dfdaa8b1ec2dd47db9e858cd047ca860196ab6b0ad350f8ccb5d2596441ccf",
10
10
  "alert.schema.json": "01a8a77840be8ab7901ed7d07c3d68a2295bfaeb638694564e19e60be1747054",
11
11
  "approval.decision.schema.json": "a9c7bc74d146465afde1d5830839165a02f232b830b5f6cfd8c8e91fb6da1563",
12
12
  "audit.event.schema.json": "e9ceb58ea00077151373955957b8319324de488c80da4063f6d0dcb40a8b3ed0",
13
- "config.update.schema.json": "f5318f24230f542c76a3cb996d08f33210c0b2cc2fc55ad73c4f95648582033f",
13
+ "config.update.schema.json": "432438b0c93ddafa0741e47f99b7357ad5f2b06b606779bf846c282e2f8a5639",
14
14
  "content.index.schema.json": "1abc24344435f0f392d5b626c3abb6879d8f9dd2c1d0463840a67634a5caa71e",
15
15
  "content.request.schema.json": "440e3675246657e9aae23e51176168bc21c91283b5fcaf6782111a18110bf76e",
16
16
  "content.response.schema.json": "c9a66ac9cbbcaec7fc182b3e3fa23b34877bd342592521c7e393779230332231",
@@ -19,24 +19,24 @@
19
19
  "error.schema.json": "b1fd663d4a1ea73d88cb575422f15f8c7279001f9385ebb2d5e1889b5d5ef878",
20
20
  "file.list.body.schema.json": "dd58ac2af91bbde53e0fd585dc55af0adb01380492d1604f21ed8e972d7ba8d1",
21
21
  "heartbeat.schema.json": "8aff84a1ef3ca56bdff8819abb3b0ab01a2547814c2ea2f9ee893cb08f7a08f1",
22
- "hello.schema.json": "bd500320f01ff10da49ded6f99c7e8081252019984afb3c178e27003df20b963",
23
- "incident.candidate.schema.json": "b292a85917b5958fc6de10a2895732e30a35420385623930c78e47341862dc62",
24
- "index.json": "cf8f05e70b6265a169b0117d4577f78b699818eb5091df6cd4c7c7416385582a",
22
+ "hello.schema.json": "a90a6bf0e9a8a20e4c6edcf2137931cf044846e7924977228275ef648a48934f",
23
+ "incident.candidate.schema.json": "ffec36aca6916079debc2c6060d093819ea3ea660b57c0c92ac3b541a0e99000",
24
+ "index.json": "de79c065217e1fa8430c4266ec19fd57df35fc4182eb7c7246b6f1ea03d2630c",
25
25
  "job.assign.schema.json": "fdebb4ae98bf38a2c723281c1cf9477f71d877171df9a55b0baeb0b65244f60c",
26
26
  "job.cancel.schema.json": "40ef64373283e17306d857c9a40081689786015294b0b88ab8f68f063bd2cfb8",
27
27
  "job.event.schema.json": "493d08f9db4f7ffd4a97403ee33ca4a73a65dc8f774d8f18ef0a137fdb5fc470",
28
28
  "job.result.schema.json": "9cbc61211482557283b8f4df44e79011ead1abbacdb2d986138199c747edc043",
29
29
  "log.stats.schema.json": "4b33b111946a19793a60756793294e90911b06f7050e48821759f2497ec17641",
30
- "ready.schema.json": "6e8bd8e1a5ce1b840d35af911ae09b8f58ad9de59eb33cd2b7777859892813f4",
31
- "register.schema.json": "e75a4aa9d5d750e1473f71ab2f37d8e9a82fcfab3e8ff1c35118d0c08916e795",
30
+ "ready.schema.json": "02e51ba5aad3357c9840c14eb45cb86b7aab0c10e349c94c720cda1a0b5b8366",
31
+ "register.schema.json": "bec30062cf99657a1ecb94bad004f502615901e36bc2b0ba62351ed021e059ac",
32
32
  "scan.upload.schema.json": "b1bba86ee362f73afc94c8c216977c12f67f770490e81002d14789397692f23b",
33
33
  "scan.uploaded.schema.json": "f4c6aed7b9363b3ce1a9e14f9c4f4fb003bcf104b60acf74421dd0c75fc5d229",
34
- "webhook.forward.schema.json": "33d4ee238855339850ff198d1078087f3b2f48c8f290b71def93cc02dd32c985",
35
- "vectors/ack.vectors.json": "242ec2b8ff0c748eaaedce63e0015cf5bd9a7092d1055b0670ce6b95403b54b2",
34
+ "webhook.forward.schema.json": "9ab66adf31386445c2b76930155ffa6874326395d968582d2345ee7fb5b77f82",
35
+ "vectors/ack.vectors.json": "1756c1169cf5f42c0ab6b1c094071d1f8ccf2fe9efdbc97101e402e93bd65a67",
36
36
  "vectors/alert.vectors.json": "0bf179ff390bbecf07a117b80237cb7ac8778864e5780f36d68c8c9a64db8f04",
37
37
  "vectors/approval.decision.vectors.json": "338115973bf2c4390d2bb33ce8f4079f3132cd739cd5505e1e873fdc252d2271",
38
38
  "vectors/audit.event.vectors.json": "d05c1820103eef041a84eb8b13d9a67a8a5b54b9b54ee3b6e43fd4ef15ed7000",
39
- "vectors/config.update.vectors.json": "8a5088d547e3eb4e460b25a5e413caaa02d4402a601340e279166d8c17758e1c",
39
+ "vectors/config.update.vectors.json": "e61865fad9b4efa808e02877c4352d3720c8069a0b31261701b369d8ae11f9cf",
40
40
  "vectors/content.index.vectors.json": "f167db30910989c46f317313d651ca59f9cd9d881d2ea81b84912155d18227b7",
41
41
  "vectors/content.request.vectors.json": "fdf0f4d9e19f6480e20e40f4652bf8205da2b113620f8461dfaeb0d61add36a0",
42
42
  "vectors/content.response.vectors.json": "cbb47cbf8a9512aa5360eea425f0cdd9064334311d37834dc75bddff07dadc5d",
@@ -44,15 +44,15 @@
44
44
  "vectors/error.vectors.json": "f5fd7954f2c1736ee0943eb330c2f85b7fdd7a8959e4f043f9cc9e9bcda39aa5",
45
45
  "vectors/file.list.body.vectors.json": "da1dcc5d4ec976853161bef4b3947572f36b5526cbe16b838ac4ef72ddded403",
46
46
  "vectors/heartbeat.vectors.json": "cfd2e048139c67aa925f1fa94c05326e75248839645c1a1e4d6d3bb60cafa624",
47
- "vectors/hello.vectors.json": "51067dc645a2e9d8116997b36e6f3fe36401d27f3ee3c18de32ec30f8a052c5f",
48
- "vectors/incident.candidate.vectors.json": "0dd75ef396bf7b7911d20b3ab220469701f07866bb032ee31af58523c3633ab6",
47
+ "vectors/hello.vectors.json": "7a50c4e54e6a4ef1227fd7485071bca8ec7b797c3b2907fddda3d8836c3feab6",
48
+ "vectors/incident.candidate.vectors.json": "a78eb4b103f411f1ec64d9eeb4c39623851e710ac0cc20b4d78b2739b7ff78d5",
49
49
  "vectors/job.assign.vectors.json": "e510c2cc6d17238d477429c255d828c0339b504e39a3a50d4b6ca980e262d9ef",
50
50
  "vectors/job.cancel.vectors.json": "0fc9f99c8662435e9425389068adf418273489dc73928a21eae1b283256b9bc5",
51
51
  "vectors/job.event.vectors.json": "03a2a2435918ba561821916bf4e435f2780210900139ecf68091acac6abf200c",
52
52
  "vectors/job.result.vectors.json": "05995fbe65e5357705d75e9b660b517f54c6902069d36b62190b1b79f283e7a5",
53
53
  "vectors/log.stats.vectors.json": "a8a67e09ef0f87b81f4028b77427ed7afd809c3f8534b8423a712b2ceef9a27f",
54
- "vectors/ready.vectors.json": "a584da17e714a13c3583256773ef0be007660c33c62a456e27568def391e7cef",
55
- "vectors/register.vectors.json": "e2a696efc547aa2f7242128d1a860238954f9e9a1330f10b567122d7b3da0365",
54
+ "vectors/ready.vectors.json": "16327f5e5eccc57a0ec635bcde96116feeeeec7ddbc4b621d4d070738cdc48d5",
55
+ "vectors/register.vectors.json": "b612e9462b1e2ac198442afccf450f1535daadc4c32d87f6a99b9752c343c486",
56
56
  "vectors/scan.upload.vectors.json": "e8a6f73fe91ac58a3222b68cf28124c1254aa4f00c3ef8f472deb7de31cab76e",
57
57
  "vectors/scan.uploaded.vectors.json": "7314ea15b1afa7b9625048fb7e245492d8910bc0dae8a52ea62afe2a1e240382",
58
58
  "vectors/traces/invalid-agent-mismatch.json": "5f17b867de71c1bcc6c42723acb53a50338c0e491193499ea25c2220ea4fa558",
@@ -76,11 +76,19 @@
76
76
  "vectors/traces/invalid-signature-warn-with-enforce-required.json": "71b5b3dc5dce52417173934db1e0de6542ecae5798e87124d54a8017493312e1",
77
77
  "vectors/traces/invalid-tenant-mismatch.json": "dcc04ea423bddef20524ead7bfd146a5d9c42fe474c9216dc38c90544d291151",
78
78
  "vectors/traces/invalid-type-outside-allowlist.json": "e492d326cfa044d3fc9f7e48dee63a52b715a62ba4867fc5ce21f1d86e3c435f",
79
+ "vectors/traces/invalid-webhook-chunk-after-last.json": "9a35faaeeafce045a887e643f603ca69950872d62d27d17086513103999aefd6",
80
+ "vectors/traces/invalid-webhook-chunk-body-hash-changes.json": "7ade576a8dc8e9671383e85fbe6aae414863cbb1cde38737a2cb78efce9c9528",
81
+ "vectors/traces/invalid-webhook-chunk-last-flag-wrong.json": "8505b5fbbc0a8dc97021a1c02f9883df95f9401bc18eaa2498331b0b6b7d155c",
82
+ "vectors/traces/invalid-webhook-chunk-non-last-too-short.json": "ff1d64b019c082e7a5c6537fdbf069025a532c764e62c532a24a3b7257f4b6ab",
83
+ "vectors/traces/invalid-webhook-chunk-out-of-order.json": "b6baa056b4e3347deaf0a4b901a126a3f93d20627faaa3951028bd47894715f4",
84
+ "vectors/traces/invalid-webhook-chunk-sha-mismatch.json": "1bb79d0e1794cd791625ed2a7eab154e20150c5d7bee7c86aa97f9bf4aaeab6d",
85
+ "vectors/traces/invalid-webhook-chunk-total-changes.json": "5edf3c1916927f94ef15061a13a6ded8a0168cb0492158f34db8160c0d6bdbe4",
79
86
  "vectors/traces/valid-content-chunks-in-order.json": "c5fc301527b062b30c625da2798c7cfab3e3debac29a36f6ec7c699705267ab3",
80
87
  "vectors/traces/valid-content-index.json": "9305dc955c4cca1f09158a9c8cf21d9e89d6a6b03646088c547f64a61cd59c30",
81
88
  "vectors/traces/valid-handshake-and-job.json": "9aa3c423c496403ca77d63965f1805f0a57ea9f6f8a01351310515b265aeb9cc",
82
89
  "vectors/traces/valid-resume-continues-seq.json": "8aa90f24598f8f2d87a5355ec58c3531d16fd11ad41c782f53eca063372b704a",
83
90
  "vectors/traces/valid-retransmit-is-idempotent.json": "924e9d296a67f0006c289e7cc978b9529ae1aa03bec2cd331a62f31354a90257",
84
- "vectors/webhook.forward.vectors.json": "38d5b53b8fb4ecdb051e2c594afc52eb8b2044e4e6aae6ffb0057fbda33bcd46"
91
+ "vectors/traces/valid-webhook-chunks-in-order.json": "2e5e6313a7fe9b3f7fea23d6a0c928d6bfef69b2a7179f1578230425f451ada9",
92
+ "vectors/webhook.forward.vectors.json": "991a677d72a9c698ed3c8d960af532daf8f3158d77a4bea7d08750ccd8693250"
85
93
  }
86
94
  }
@@ -5,13 +5,16 @@ sides. Design: vault `10 Library/Plans/Fase 3 Desenho Tecnico 2026-10-28.md` sec
5
5
  implements it behind `deepdebug.agent-ws.protocol-v2.enabled` (default off, F3-05a); the LA side (F3-05b) does not
6
6
  exist yet. v1 is untouched. The gateway packages `*.schema.json` + `index.json` from this folder as classpath
7
7
  `la-protocol-v2/` (pom resource; both Dockerfiles copy this folder), so this folder stays the only source.
8
+ **Revision 2.1** (`index.json` `revision`, Fase 3, last section): optional LLM key delivery, chunked webhooks, size/model in `incident.candidate`, `ready.capabilities`.
8
9
 
9
10
  ## Files
10
11
  - `envelope.schema.json`: common frame, closed (`additionalProperties:false`). `<type>.schema.json` (22):
11
12
  one per allowlisted type, each `$ref`s the envelope and closes `payload`. `defs.schema.json`: shared types.
12
13
  - `file.list.body.schema.json` (F3-07c): not a frame type; the schema of the DECODED body of a `content.response` for kind `file.list`
13
14
  (below). `vectors/file.list.body.vectors.json` holds its vectors (same format, `schema` names that file).
14
- - `index.json`: allowlist, direction, signed/control flags, content class per type, and the limits below.
15
+ - `index.json`: allowlist, direction, signed/control flags, content class per type (`none`, `relay`, `transit`, `secret`), the limits below and the contract `revision`.
16
+ - Vector files of a BODY that travels inside a frame (not a frame) are named `*.body.vectors.json` and their schema `*.body.schema.json`; the frame
17
+ suites skip them (`ProtocolV2ContractTest` checks them against their own schema, `SchemaLaV2ContractTest` does not read them as frames).
15
18
  - `vectors/<type>.vectors.json` (+ `cross-type`): valid and invalid messages with `expect` (`valid`, error `code`,
16
19
  optional JSON Schema `keyword`, optional session `rule`). `vectors/traces/*.json`: whole sessions for the stateful rules.
17
20
 
@@ -30,7 +33,8 @@ ack timeout 5 s, <= 3 retransmissions; handshake steps 10 s; `ping`/`heartbeat`
30
33
  (= Redis `la:session` TTL); clock skew <= 60 s. Duplicate detection (gateway, F3-05a): the last 512 `seq`->`msgId` pairs
31
34
  per session, in memory (`dedupe-entries`); the 10 min Redis window of the design (`dedupeWindowSeconds` in `index.json`)
32
35
  is NOT implemented, a replay older than those 512 frames is `seq_replayed`. CP->LA frames repeat the payload in
33
- `paramsRaw`, so their effective payload budget is about 20 KiB; larger inputs travel by `ref`.
36
+ `paramsRaw`, so their effective payload budget is about 20 KiB; larger inputs travel by `ref`. Webhook chunking (2.1) has its own limits
37
+ (`webhook*` in `index.json`: 16 KiB raw per chunk, 64 chunks, 1 MiB per webhook, see the last section).
34
38
 
35
39
  ## State machine
36
40
  `AWAIT_REGISTER -> AWAIT_HELLO -> AWAIT_READY -> READY`, after the WS upgrade authenticates (Bearer, as v1).
@@ -45,7 +49,9 @@ is NOT implemented, a replay older than those 512 frames is `seq_replayed`. CP->
45
49
  handshake state, `seq` (same seq + same `msgId` = idempotent duplicate, re-ack; same seq + other `msgId` =
46
50
  `seq_replayed`; skip = `seq_gap`), chunks (index 0.., `total` constant, `last` iff last index, none after last; then `payload.byteLength` must equal the size of
47
51
  the base64-DECODED `payload.data`, else `chunk_inconsistent`: JSON Schema cannot state it, so the vector files carry
48
- `expect.rule:"byteLength-decoded"` and both validators apply it).
52
+ `expect.rule:"byteLength-decoded"` and both validators apply it). Since 2.1 two more stateless rules of the same kind:
53
+ `webhook-chunk-size` and `llmKey-id-sha256` (last section); `webhook.forward` chunks are also grouped by `payload.webhookId` and the reassembled
54
+ body must hash to `bodySha256` (`chunk_inconsistent` otherwise).
49
55
  - **Errors** (`error{code,retryable}`, then WS close 4400-4499 when fatal): `bad_frame unknown_type schema_violation
50
56
  frame_too_large payload_too_large wrong_direction handshake_violation not_ready seq_gap seq_replayed
51
57
  chunk_out_of_order chunk_inconsistent tenant_mismatch agent_mismatch params_mismatch signature_mode_insufficient
@@ -64,6 +70,7 @@ Everything is closed: no free-text fields, no extra properties, free strings <=
64
70
  hashes, counts). Client content may exist only in three declared fields (`x-content-class`, pinned by the test):
65
71
  `content.response.data` (relay, LA->CP, never persisted or logged by the CP), `job.assign.inputs[].inline` and
66
72
  `webhook.forward.bodyB64` (transit, CP->LA, `transient:true`, not persisted; `paramsRaw` echoes them).
73
+ Since 2.1 there is one more classified field and it is the only SECRET: `config.update.llmKey.key` (`x-content-class: secret`, see the last section).
67
74
  Long non-content strings are marked `x-large-metadata` (`paramsRaw`, `signedPutUrl`, `vhapr1` token). `error.detail` <= 200 chars
68
75
  and `audit.event` have no summary text; stack traces, diffs, logs and paths go only through `content.*`.
69
76
 
@@ -186,3 +193,96 @@ existing kinds, `ref` convention, ids, envelope and chunking are reused; the cha
186
193
  answer is HTTP 501 `not_supported`. That announcement is also how the front/CP discovers that listing exists on a given agent.
187
194
  - **Compatibility.** Additive: the kind is added to the `content.request` and `content.index` enums, nothing else changes there. The
188
195
  `content.request` payload gained no field. With `deepdebug.content-relay.enabled` off nothing is mapped, as before.
196
+
197
+ ## Revision 2.1 (Fase 3, contract v2.1): LLM key, chunked webhooks, size and model of a candidate
198
+ Decisions of Edu 2026-11-03 (ADR-0017, adenda (c)). **No new frame type** (still 22), every change is additive and optional, and each is gated by a
199
+ capability so a 2.0 peer keeps working unchanged. The gateway code that uses these fields is a later task (G); here only the contract moved.
200
+
201
+ **Capabilities (negotiation).** The LA announces, in `register` and `hello` `capabilities`: `contract.v2.1` (it has the 2.1 schemas, so it can read the
202
+ new optional `ready.payload.capabilities`), `llm.key` (it handles `config.update.llmKey`) and `webhook.chunked` (it reassembles chunked `webhook.forward`;
203
+ it announces `webhook.forward` too). The CP announces, in `ready.payload.capabilities` (array, <= 32, same name pattern): `incident.candidate.size-model` (it
204
+ accepts `stackBytes` and `model`). Rules: the CP sends `llmKey` only to a session that announced `llm.key`, a chunked webhook only to one that announced
205
+ `webhook.chunked`, and `ready.capabilities` only to one that announced `contract.v2.1` (a 2.0 LA's closed `ready` schema does not know the field, so without
206
+ `contract.v2.1` the `ready` is exactly the 2.0 one); an LA sends `stackBytes`/`model` only if `ready` listed `incident.candidate.size-model` (a 2.0 CP would
207
+ answer the closed schema with `schema_violation`). A name that is absent means 2.0 behaviour.
208
+ One more LA capability, `llm.key.held` (section (e)), tells the CP the LA holds a valid key right now; it is optional and only informs the CP.
209
+
210
+ ### (a) LLM key delivery: `config.update.payload.llmKey`
211
+ `config.update` already carried only flags, a DLP hash, a minimum version, a retention and the data-plane mode: it had no way to hand the LA the virtual
212
+ LiteLLM key (F3-09). `llmKey` is a closed `oneOf`:
213
+ - `{op:"set", keyId, endpoint, key, models[], expiresAt}`, all required. `keyId` is the 64-hex lower-case sha256 of the UTF-8 bytes of `key` (it is
214
+ LiteLLM's token id and the CP's `la_llm_keys.key_id`; semantic rule `llmKey-id-sha256`: the LA recomputes it and refuses a mismatch). `endpoint` is the AI
215
+ gateway base URL, `https://` only, host[:port][/path], no credentials, no query, no fragment, <= 256 chars. `key` is 16..256 chars of
216
+ `[A-Za-z0-9._~+/=-]` (no whitespace, no control character). `models` is 1..32 unique aliases (the scope of the key). `expiresAt` is UTC.
217
+ - `{op:"revoke", keyId}`: names the key by id, never repeats it.
218
+ - A `config.update` that carries `llmKey` carries NOTHING else (`maxProperties: 1`), so the frame can be recognised and redacted as a whole.
219
+ - **The key is a secret.** `x-content-class: secret` on `llmKey.key`; `index.json` marks `config.update` `contentClass: "secret"`. It travels only on the v2
220
+ channel (TLS plus the Ed25519 signature) and, between gateway instances, only inside the AEAD-sealed relay (F3-04). It appears twice in the frame, in
221
+ `payload` and in the `paramsRaw` copy: BOTH are secret. The CP never writes it to a log, a metric, a trace, the audit trail, a table or an unsealed Redis
222
+ value (it keeps it only in the in-memory unacked frame until acked or the session ends). The LA keeps it **only in memory or in a file mode `0600`
223
+ (directory `0700`) outside every workspace and outside what the content handlers can serve**, never logs it, never puts it in `hello`, `alert`,
224
+ `audit.event`, `job.event`, `content.response` or an error, and never passes it on a command line.
225
+ - **The ack never returns it.** The answer is the plain `ack` (its payload is only `ackSeq`, `status`, `errorCode`; the contract test pins that no LA->CP
226
+ frame has a field named `key`, `llmKey` or `keyId`): `accepted`, or `rejected` + `schema_violation` when the LA refuses the key (expired
227
+ `expiresAt`, `keyId` not the sha256 of `key`).
228
+ - **Rotation.** A `set` with another `keyId` replaces the key the LA holds, atomically (one key per LA); the same `keyId` again is idempotent. The CP should
229
+ revoke the old key at the AI gateway only after the `accepted` ack of the new one.
230
+ - **Revocation.** The key revoked at the AI gateway is the authoritative revocation; the `revoke` frame only makes the LA drop its copy (delete from memory,
231
+ unlink and overwrite the file) and stop using it. A `revoke` of an id the LA does not hold is a no-op and is acked `accepted` (it never tells which key it
232
+ has). The LA also drops the key at `expiresAt`.
233
+
234
+ ### (b) Chunking of `webhook.forward` (CP -> LA)
235
+ 2.0 allowed one frame with `bodyB64` <= 12288 chars (9216 raw bytes). That stays: **a body of at most 9216 bytes is sent as ONE frame without `chunk`, exactly
236
+ as in 2.0** (a 2.0 LA reads it). A bigger body (up to **1 MiB**) is chunked, reusing the `content.response` pattern but CP -> LA:
237
+ - Each chunk is a signed `webhook.forward` frame with the envelope `chunk{index,total,last}` (index from 0), its own `seq` and `msgId`, and the same
238
+ `webhookId`, `source`, `contentType`, **`bodyBytes`** (size of the WHOLE decoded body, 0..1048576) and **`bodySha256`** (sha256 of the whole decoded body,
239
+ lower-case hex). `bodyBytes` and `bodySha256` are required in a chunked frame and forbidden in an unchunked one (schema `if/then/else` on `chunk`).
240
+ - **Fixed chunk size**: 16384 raw bytes (`bodyB64` <= 21848 chars; twice in the frame with `paramsRaw` it still fits 48 KiB: a vector pins it).
241
+ `total = max(1, ceil(bodyBytes / 16384))` (<= 64); every non-last chunk has exactly 16384 bytes; the last has `bodyBytes - 16384 * (total - 1)`. That is the
242
+ stateless rule `webhook-chunk-size` (`chunk_inconsistent` when a frame breaks it), so the LA can size the buffer from the first chunk. A body above
243
+ 9216 bytes is never sent unchunked; a chunked frame with a small body (`total` 1) is valid.
244
+ - **Order and integrity** (reference model, same codes as `content.response`): chunks in index order, `total` constant, `last` iff the last index, none after
245
+ the last (`chunk_out_of_order` / `chunk_inconsistent`); `bodyBytes`, `bodySha256`, `source`, `contentType` must not change between chunks; on the last
246
+ chunk the reassembled body must have `bodyBytes` bytes and hash to `bodySha256`, else `chunk_inconsistent`. Each chunk is also authentic on its own (signature).
247
+ - **Acks.** Every chunk is a normal v2 frame: the LA acks each by `seq` (piggyback or `ack{ackSeq,status}`), `accepted` = stored. The `accepted` ack of the
248
+ LAST chunk means the body was reassembled and its hash verified: that is the ack the CP waits for before answering the webhook (decision: ack AFTER the
249
+ forward). The LA answers `rejected` + `chunk_inconsistent` / `chunk_out_of_order` / `payload_too_large` / `rate_limited` and drops the assembly; the CP then
250
+ stops sending chunks of that `webhookId` and treats the webhook as not delivered.
251
+ - **LA limits** (`index.json`): an assembly older than 30 s (`webhookAssemblySeconds`) without its last chunk is dropped; at most 4 open assemblies per session
252
+ (`webhookMaxOpenAssemblies`; the CP sends one chunked webhook at a time per session) and 4 MiB buffered; beyond that `rejected` + `rate_limited`. The body
253
+ stays in memory, is never written or logged by the CP, and the LA applies its DLP to it as to any client content.
254
+ - `webhookId` is the group key; a retransmitted chunk (same `seq` and `msgId`) is the existing idempotent duplicate; the same `webhookId` redelivered as a
255
+ whole is the application's dedup (a deterministic `webhookId` is the consumer's job).
256
+
257
+ ### (c) `incident.candidate`: `stackBytes` and `model`
258
+ Two OPTIONAL fields, no required field added: `stackBytes` (integer 0..16777216: size in bytes of the stack the LA saw; the LA saturates at the cap) and
259
+ `model` (string 1..48, `^[A-Za-z0-9][A-Za-z0-9._:+-]{0,47}$`: detector/parser version, e.g. `stack-detector-1.8.0`; no space, no slash, so no name, path or
260
+ error text fits). The frame still carries no stack, no message and no name. Sent only after `ready` announced `incident.candidate.size-model`.
261
+
262
+ ### (d) `ready.payload.capabilities`
263
+ Optional array (<= 32 capability names). It is what lets (c) be sent safely; it carries no limit and no content. A CP that does not send it is a 2.0 CP, or the
264
+ session did not announce `contract.v2.1`.
265
+
266
+ ### (e) `llm.key.held`: the LA says whether it still has its key
267
+ Problem it solves: an LA that keeps the key only in memory (the default) loses it on restart, but the CP's table still says ACTIVE, and the CP only
268
+ delivers again when the key is close to expiry (< 14 days), so the LA would stay without a key. Rather than the CP guessing, the LA announces it.
269
+ - **Name and place.** The capability string `llm.key.held` (it already matches the `capability` pattern, so no schema enum changes), announced in
270
+ `register.payload.capabilities` and in `hello.payload.capabilities`, never in `ready.payload.capabilities` (that list is the CP's). It is optional and
271
+ needs no `contract.v2.1` decision of its own: a CP that does not know it ignores an unknown capability name, as it ignores any other.
272
+ - **Rule for the LA.** It announces `llm.key.held` ONLY when it holds a key that is still valid (`expiresAt` in the future, accepted and
273
+ `keyId`-verified through `config.update.llmKey`) and that it is IN USE (it is the key its LLM calls would use). No key, an expired key, a revoked key
274
+ or a key it refused: it does not announce it. The announcement is a snapshot at connect time: `register` and `hello` of one session carry the same
275
+ answer, and the LA never announces it without also announcing `llm.key` (an LA that cannot receive a key cannot hold one). That dependency is in the
276
+ schema (`if capabilities contains llm.key.held then it must contain llm.key`, `register` and `hello`), so `llm.key.held` alone is `schema_violation`.
277
+ - **Rule for the CP.** At connect, if the capability is NOT announced (in `register` and `hello`; the CP should treat the key as held only when both
278
+ say so), the CP delivers a key (mint, then `config.update.llmKey set`) even when an ACTIVE row exists for that agent; the previous key follows the
279
+ rotation rule of (a) (revoked at the AI gateway only after the `accepted` ack of the new one). If it IS announced, the CP keeps the existing rule:
280
+ nothing to deliver until the key is within 14 days of `expiresAt`. A 2.0 LA, or a 2.1 LA that does not know the capability, never announces it, so
281
+ the CP treats it as "holds no key" and delivers one if the channel is on (`llm.key` announced): the safe direction.
282
+ - **Compatible.** Optional and additive: a register/hello without it is valid (2.0, or 2.1 after a restart), and one with it is valid under 2.1 only
283
+ together with `llm.key`. No frame type, no new field and no new limit. The CP side of this rule is gateway runtime code (a later task, not this contract).
284
+
285
+ ### Compatibility and roll-out order
286
+ Everything is additive and optional: a 2.0 frame is valid under the 2.1 schemas (the vectors of 2.0 are unchanged and still pass), and a 2.1 peer talking to a
287
+ 2.0 peer sends nothing the 2.0 peer cannot read, because each new field is gated by a capability the other side announced (above). Roll out the gateway that
288
+ has the 2.1 schemas first (it accepts both shapes), then the LAs. The files changed by 2.1 are listed with their sha256 in the PR; the LA re-syncs exactly those.
@@ -52,9 +52,93 @@
52
52
  },
53
53
  "dataPlaneMode": {
54
54
  "$ref": "defs.schema.json#/$defs/dataPlaneMode"
55
+ },
56
+ "llmKey": {
57
+ "oneOf": [
58
+ {
59
+ "type": "object",
60
+ "additionalProperties": false,
61
+ "properties": {
62
+ "op": {
63
+ "const": "set"
64
+ },
65
+ "keyId": {
66
+ "$ref": "defs.schema.json#/$defs/sha256"
67
+ },
68
+ "endpoint": {
69
+ "type": "string",
70
+ "minLength": 12,
71
+ "maxLength": 256,
72
+ "pattern": "^https://[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?(:[0-9]{1,5})?(/[A-Za-z0-9._~/-]{0,200})?$"
73
+ },
74
+ "key": {
75
+ "type": "string",
76
+ "minLength": 16,
77
+ "maxLength": 256,
78
+ "pattern": "^[A-Za-z0-9._~+/=-]{16,256}$",
79
+ "x-content-class": "secret"
80
+ },
81
+ "models": {
82
+ "type": "array",
83
+ "minItems": 1,
84
+ "maxItems": 32,
85
+ "uniqueItems": true,
86
+ "items": {
87
+ "type": "string",
88
+ "minLength": 1,
89
+ "maxLength": 96,
90
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,95}$"
91
+ }
92
+ },
93
+ "expiresAt": {
94
+ "$ref": "defs.schema.json#/$defs/ts"
95
+ }
96
+ },
97
+ "required": [
98
+ "op",
99
+ "keyId",
100
+ "endpoint",
101
+ "key",
102
+ "models",
103
+ "expiresAt"
104
+ ]
105
+ },
106
+ {
107
+ "type": "object",
108
+ "additionalProperties": false,
109
+ "properties": {
110
+ "op": {
111
+ "const": "revoke"
112
+ },
113
+ "keyId": {
114
+ "$ref": "defs.schema.json#/$defs/sha256"
115
+ }
116
+ },
117
+ "required": [
118
+ "op",
119
+ "keyId"
120
+ ]
121
+ }
122
+ ]
55
123
  }
56
124
  },
57
- "minProperties": 1
125
+ "minProperties": 1,
126
+ "if": {
127
+ "type": "object",
128
+ "properties": {
129
+ "llmKey": true
130
+ },
131
+ "required": [
132
+ "llmKey"
133
+ ]
134
+ },
135
+ "then": {
136
+ "type": "object",
137
+ "properties": {
138
+ "llmKey": true
139
+ },
140
+ "maxProperties": 1
141
+ }
58
142
  },
59
143
  "seq": {
60
144
  "type": "integer",
@@ -94,7 +94,32 @@
94
94
  "clockEpochMs",
95
95
  "diskFreeBytes",
96
96
  "queueDepth"
97
- ]
97
+ ],
98
+ "if": {
99
+ "properties": {
100
+ "capabilities": {
101
+ "type": "array",
102
+ "maxItems": 32,
103
+ "contains": {
104
+ "const": "llm.key.held"
105
+ }
106
+ }
107
+ },
108
+ "required": [
109
+ "capabilities"
110
+ ]
111
+ },
112
+ "then": {
113
+ "properties": {
114
+ "capabilities": {
115
+ "type": "array",
116
+ "maxItems": 32,
117
+ "contains": {
118
+ "const": "llm.key"
119
+ }
120
+ }
121
+ }
122
+ }
98
123
  },
99
124
  "seq": {
100
125
  "const": 0
@@ -72,6 +72,17 @@
72
72
  "security",
73
73
  "other"
74
74
  ]
75
+ },
76
+ "stackBytes": {
77
+ "type": "integer",
78
+ "minimum": 0,
79
+ "maximum": 16777216
80
+ },
81
+ "model": {
82
+ "type": "string",
83
+ "minLength": 1,
84
+ "maxLength": 48,
85
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._:+-]{0,47}$"
75
86
  }
76
87
  },
77
88
  "required": [