hippo-memory 1.56.0 → 1.57.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 (67) hide show
  1. package/dist/api.d.ts +16 -9
  2. package/dist/api.js +57 -24
  3. package/dist/card-detail.d.ts +1 -1
  4. package/dist/card-detail.js +1 -1
  5. package/dist/cli/shared.d.ts +137 -0
  6. package/dist/cli/shared.js +830 -0
  7. package/dist/cli/sleep.d.ts +10 -0
  8. package/dist/cli/sleep.js +171 -0
  9. package/dist/cli.d.ts +0 -7
  10. package/dist/cli.js +223 -1789
  11. package/dist/connectors/github/webhook.d.ts +19 -0
  12. package/dist/connectors/github/webhook.js +313 -0
  13. package/dist/connectors/slack/webhook.d.ts +22 -0
  14. package/dist/connectors/slack/webhook.js +203 -0
  15. package/dist/consolidate.js +3 -2
  16. package/dist/context-auto.d.ts +3 -0
  17. package/dist/context-auto.js +34 -0
  18. package/dist/customer-notes.js +2 -1
  19. package/dist/dashboard.js +2 -1
  20. package/dist/decisions.js +2 -1
  21. package/dist/eval-stats.d.ts +58 -0
  22. package/dist/eval-stats.js +111 -0
  23. package/dist/goals.d.ts +49 -25
  24. package/dist/goals.js +39 -22
  25. package/dist/graph-extract.js +1 -1
  26. package/dist/graph-recall.d.ts +1 -1
  27. package/dist/graph-recall.js +1 -1
  28. package/dist/graph.js +1 -1
  29. package/dist/hooks.d.ts +1 -3
  30. package/dist/hooks.js +2 -4
  31. package/dist/http-util.d.ts +31 -0
  32. package/dist/http-util.js +46 -0
  33. package/dist/incidents.js +2 -1
  34. package/dist/index.d.ts +5 -2
  35. package/dist/index.js +5 -2
  36. package/dist/mcp/server.js +173 -285
  37. package/dist/memory.d.ts +19 -0
  38. package/dist/memory.js +38 -0
  39. package/dist/policies.js +2 -1
  40. package/dist/predictions.js +2 -1
  41. package/dist/processes.js +2 -1
  42. package/dist/project-briefs.js +3 -1
  43. package/dist/prompt-recall.js +1 -1
  44. package/dist/recall-history.d.ts +5 -0
  45. package/dist/recall-history.js +9 -0
  46. package/dist/recall-pipeline.d.ts +101 -0
  47. package/dist/recall-pipeline.js +313 -0
  48. package/dist/recall-scope.d.ts +22 -0
  49. package/dist/recall-scope.js +27 -1
  50. package/dist/search.d.ts +0 -20
  51. package/dist/search.js +2 -49
  52. package/dist/server.js +1901 -2384
  53. package/dist/skills.js +2 -1
  54. package/dist/store-cards.d.ts +53 -0
  55. package/dist/store-cards.js +512 -0
  56. package/dist/store.d.ts +2 -89
  57. package/dist/store.js +6 -562
  58. package/dist/tenant.d.ts +22 -0
  59. package/dist/tenant.js +26 -0
  60. package/dist/tokenize.d.ts +2 -0
  61. package/dist/tokenize.js +8 -0
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  65. package/extensions/openclaw-plugin/package.json +1 -1
  66. package/openclaw.plugin.json +1 -1
  67. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -7,7 +7,7 @@ import { detectServer, writePidfile, removePidfileIfOwned } from './server-detec
7
7
  import { resolveTenantId } from './tenant.js';
8
8
  import { openHippoDb, closeHippoDb } from './db.js';
9
9
  import { updateStats } from './store.js';
10
- import { buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, hashQueryText, } from './recall-history.js';
10
+ import { buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, hashQueryText, biasHintEnabled, } from './recall-history.js';
11
11
  import { appendAuditEvent, auditQueryFields, AUDIT_OPS } from './audit.js';
12
12
  // v0.33 / J1 — Module-level per-(tenant, session) recall-history ring map
13
13
  // for the HTTP pipeline. Separate from CLI/MCP rings per plan v3 (per-
@@ -23,7 +23,7 @@ export function __resetSessionRecallHistoryHttp() {
23
23
  import { PACKAGE_VERSION } from './version.js';
24
24
  import { API_KEY_PREFIX, validateApiKey } from './auth.js';
25
25
  import { createRateLimiter } from './rate-limit.js';
26
- import { remember, retrieve, RecallContractError, ForbiddenError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, adminActor, recordTokens, quarantineList, quarantineApprove, quarantineReject, } from './api.js';
26
+ import { remember, retrieve, RecallContractError, ForbiddenError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, recordTokens, quarantineList, quarantineApprove, quarantineReject, } from './api.js';
27
27
  import { buildGraphModel } from './graph-view.js';
28
28
  import { MAX_ENTITY_NAME_LEN } from './graph.js';
29
29
  import { savePrediction, closePrediction, loadPredictionById, loadPredictionsByClass, loadOpenPredictions, computePredictionBaserate, VALID_CLOSURE_STATES, } from './predictions.js';
@@ -35,19 +35,9 @@ import { saveSkill, closeSkill, loadSkillById, loadSkills, exportSkills, VALID_S
35
35
  import { saveProjectBrief, closeProjectBrief, loadProjectBriefById, loadProjectBriefs, assembleBriefFromReceipts, refreshBrief, VALID_BRIEF_STATES, } from './project-briefs.js';
36
36
  import { saveCustomerNote, closeCustomerNote, loadCustomerNoteById, loadCustomerNotes, VALID_NOTE_STATES, } from './customer-notes.js';
37
37
  import { handleMcpRequest } from './mcp/server.js';
38
- import { verifySlackSignature } from './connectors/slack/signature.js';
39
- import { isSlackEventEnvelope, isSlackMessageEvent } from './connectors/slack/types.js';
40
- import { ingestMessage } from './connectors/slack/ingest.js';
41
- import { handleMessageDeleted } from './connectors/slack/deletion.js';
42
- import { writeToDlq } from './connectors/slack/dlq.js';
43
- import { resolveTenantForTeam } from './connectors/slack/tenant-routing.js';
44
- import { verifyGitHubSignature } from './connectors/github/signature.js';
45
- import { isGitHubWebhookEnvelope, isGitHubIssueEvent, isGitHubIssueCommentEvent, isGitHubPullRequestEvent, isGitHubPullRequestReviewCommentEvent, } from './connectors/github/types.js';
46
- import { ingestEvent as ingestGitHubEvent } from './connectors/github/ingest.js';
47
- import { handleCommentDeleted as handleGitHubCommentDeleted } from './connectors/github/deletion.js';
48
- import { writeToDlq as writeToGitHubDlq } from './connectors/github/dlq.js';
49
- import { resolveTenantForGitHub } from './connectors/github/tenant-routing.js';
50
- import { computeDeletionKey as computeGitHubDeletionKey } from './connectors/github/signature.js';
38
+ import { handleSlackEventsWebhook } from './connectors/slack/webhook.js';
39
+ import { handleGitHubEventsWebhook } from './connectors/github/webhook.js';
40
+ import { HttpError, JSON_HEADERS, BodyTooLargeError, isHeaderString, isJsonObjectRecord, readBody, sendJson, } from './http-util.js';
51
41
  // Review patch #2: explicit allow-list for unauthenticated /v1/* routes.
52
42
  // New unauth routes MUST be added here AND get a corresponding entry in
53
43
  // tests/server-bearer-lockdown.test.ts. Do not gate auth elsewhere by
@@ -78,14 +68,6 @@ function isJsonNumber(value) {
78
68
  function isJsonBoolean(value) {
79
69
  return typeof value === 'boolean';
80
70
  }
81
- function isJsonObjectRecord(value) {
82
- return value !== undefined && value !== null && typeof value === 'object' && !Array.isArray(value);
83
- }
84
- // node:http header values are `string | string[] | undefined` (never a bare
85
- // unknown), so this gets its own predicate rather than reusing isJsonString.
86
- function isHeaderString(value) {
87
- return typeof value === 'string';
88
- }
89
71
  // server.address() returns AddressInfo once a TCP socket is bound; null before
90
72
  // listening, a string only for pipe/unix-socket listeners (never used here).
91
73
  function isAddressInfo(a) {
@@ -173,47 +155,10 @@ const VALID_KINDS = new Set([
173
155
  // avoid for tests that mkdtemp a hippoRoot.
174
156
  // v1.3.1: source from src/version.ts so /health no longer reports stale 0.39.0.
175
157
  const VERSION = PACKAGE_VERSION;
176
- // 1 MB body cap. The CLI never sends payloads near this; anything bigger is
177
- // almost certainly a misconfigured client or a deliberate memory-blowup attempt.
178
- const MAX_BODY_BYTES = 1024 * 1024;
179
158
  const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
180
- const JSON_HEADERS = { 'content-type': 'application/json' };
181
- class HttpError extends Error {
182
- status;
183
- constructor(status, message) {
184
- super(message);
185
- this.status = status;
186
- }
187
- }
188
- class BodyTooLargeError extends Error {
189
- }
190
- function sendJson(res, status, body) {
191
- res.writeHead(status, JSON_HEADERS);
192
- res.end(JSON.stringify(body));
193
- }
194
159
  function sendError(res, status, message) {
195
160
  sendJson(res, status, { error: message });
196
161
  }
197
- /**
198
- * Read the entire request body into a Buffer. Caps at MAX_BODY_BYTES to keep
199
- * a malicious or buggy client from exhausting memory. The cap is enforced
200
- * mid-stream so we don't wait for an attacker to finish before erroring out.
201
- */
202
- async function readBody(req) {
203
- const chunks = [];
204
- let total = 0;
205
- for await (const chunk of req) {
206
- // SAFETY: IncomingMessage never runs setEncoding() here, so every
207
- // streamed chunk is a Buffer, not a decoded string.
208
- const buf = chunk;
209
- total += buf.length;
210
- if (total > MAX_BODY_BYTES) {
211
- throw new BodyTooLargeError('request body exceeds 1MB');
212
- }
213
- chunks.push(buf);
214
- }
215
- return Buffer.concat(chunks).toString('utf8');
216
- }
217
162
  async function parseJsonBody(req) {
218
163
  const raw = await readBody(req);
219
164
  if (raw.length === 0)
@@ -609,2455 +554,2027 @@ function validateIdSegment(id, fieldName) {
609
554
  throw new HttpError(400, `${fieldName} contains invalid characters; allowed: A-Z a-z 0-9 _ : . -`);
610
555
  }
611
556
  }
612
- async function handleRequest(req, res, opts, startedAt, limiter) {
613
- // v1.6.4: pre-decode raw-URL slash check. Catches `%2F` / `%2f` before
614
- // Node's URL parser collapses them and they slip past the route table.
615
- rejectEncodedSlash(req.url ?? '/');
616
- const { method, path, query } = parseRequest(req);
617
- if (method === 'GET' && path === '/health') {
618
- // Loopback callers (detectServer's stale-pidfile probe reads version and
619
- // pid) get the full body. Non-loopback callers get liveness only: the
620
- // version string would fingerprint the build for the public internet and
621
- // the pid is noise. Platform health checks only need the 200.
622
- if (isLoopback(req.socket.remoteAddress)) {
623
- sendJson(res, 200, {
624
- ok: true,
625
- version: VERSION,
626
- started_at: startedAt,
627
- pid: process.pid,
628
- });
557
+ // POST /v1/memories
558
+ async function handleCreateMemory({ req, res, opts }) {
559
+ const body = await parseJsonBody(req);
560
+ const content = getString(body, 'content');
561
+ if (!content) {
562
+ throw new HttpError(400, 'content is required');
563
+ }
564
+ const kindRaw = getString(body, 'kind');
565
+ if (kindRaw !== undefined && !isSetMember(VALID_KINDS, kindRaw)) {
566
+ throw new HttpError(400, `invalid kind: ${kindRaw}`);
567
+ }
568
+ const ctx = await buildContextWithAuth(req, opts);
569
+ const result = remember(ctx, {
570
+ content,
571
+ kind: kindRaw,
572
+ scope: getString(body, 'scope'),
573
+ owner: getString(body, 'owner'),
574
+ artifactRef: getString(body, 'artifactRef'),
575
+ tags: getStringArray(body, 'tags'),
576
+ });
577
+ sendJson(res, 200, result);
578
+ return;
579
+ }
580
+ // GET /v1/graph?entity=NAME&limit=N — read-only entity/relation graph (tenant-scoped)
581
+ async function handleGetGraph({ req, res, opts, query }) {
582
+ const entityRaw = query.get('entity');
583
+ // Cap at the graph entity-name cap (512), not the id-shaped 256, so a valid
584
+ // long decision/policy name remains focusable over HTTP (codex P2).
585
+ if (entityRaw !== null && entityRaw.length > MAX_ENTITY_NAME_LEN) {
586
+ throw new HttpError(400, `entity exceeds the ${MAX_ENTITY_NAME_LEN}-character cap`);
587
+ }
588
+ const limit = parseListLimit(query.get('limit'));
589
+ const ctx = await buildContextWithAuth(req, opts);
590
+ const model = buildGraphModel(ctx.hippoRoot, ctx.tenantId, {
591
+ entity: entityRaw ?? undefined,
592
+ limit,
593
+ });
594
+ sendJson(res, 200, model);
595
+ return;
596
+ }
597
+ // GET /v1/memories?q=...&limit=...&mode=...&scope=...&include_continuity=1
598
+ async function handleRecallMemories({ req, res, opts, query }) {
599
+ const q = query.get('q');
600
+ if (!q) {
601
+ throw new HttpError(400, 'q is required');
602
+ }
603
+ const limitRaw = query.get('limit');
604
+ const limit = limitRaw === null ? undefined : parseListLimit(limitRaw);
605
+ const mode = query.get('mode');
606
+ if (mode !== null && mode !== 'bm25' && mode !== 'hybrid' && mode !== 'physics') {
607
+ throw new HttpError(400, "mode must be 'bm25', 'hybrid', or 'physics'");
608
+ }
609
+ const scope = query.get('scope');
610
+ const includeContinuityRaw = query.get('include_continuity');
611
+ const includeContinuity = includeContinuityRaw === '1'
612
+ || includeContinuityRaw === 'true';
613
+ // v1.6.2: surface the v1.5.0/v1.5.2 RecallOpts additions to HTTP
614
+ // callers. Pre-v1.6.2 the route silently ignored these so the
615
+ // session-scoped fresh-tail and summary substitution were JS-only.
616
+ const freshTailCountRaw = query.get('fresh_tail_count');
617
+ const freshTailCount = freshTailCountRaw === null ? undefined : Number(freshTailCountRaw);
618
+ if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
619
+ throw new HttpError(400, 'fresh_tail_count must be a non-negative number');
620
+ }
621
+ // v1.6.3 senior-review P1-3: cap session_id length consistent with the
622
+ // rest of the API. Untrimmed strings round-trip through the SQL layer
623
+ // and through any downstream metric/log; 256 is generous for a session
624
+ // id and matches the rest of this file's id-shaped param parsers.
625
+ const freshTailSessionIdRaw = query.get('fresh_tail_session_id');
626
+ if (freshTailSessionIdRaw !== null && freshTailSessionIdRaw.length > 256) {
627
+ throw new HttpError(400, 'fresh_tail_session_id exceeds 256-character cap');
628
+ }
629
+ const freshTailSessionId = freshTailSessionIdRaw && freshTailSessionIdRaw.length > 0
630
+ ? freshTailSessionIdRaw
631
+ : undefined;
632
+ // v1.6.3 senior-review P1-4: tighten parser to match the includeContinuity
633
+ // convention. Pre-v1.6.3 accepted any non-'0'/'false' value as `true`,
634
+ // so `?summarize_overflow=banana` and `?summarize_overflow=` both
635
+ // turned it on. Surface convention drift fixed.
636
+ const summarizeOverflowRaw = query.get('summarize_overflow');
637
+ const summarizeOverflow = summarizeOverflowRaw === null
638
+ ? undefined
639
+ : (summarizeOverflowRaw === '1' || summarizeOverflowRaw === 'true');
640
+ // recall() owns the shape rule (NaN, 0 and negatives throw invalid_scorer_window); the transport caps remote cost.
641
+ const scorerWindowRaw = query.get('scorer_window');
642
+ const scorerWindow = scorerWindowRaw === null ? undefined : Number(scorerWindowRaw);
643
+ if (scorerWindow !== undefined && scorerWindow > 1000) {
644
+ throw new HttpError(400, 'scorer_window must be <= 1000');
645
+ }
646
+ // v1.7.4: session_id for the dlPFC goal-stack boost. 256-char cap mirrors
647
+ // fresh_tail_session_id (above). Trim then drop if empty so api.recall
648
+ // sees undefined when the param is omitted or whitespace-only.
649
+ const sessionIdRaw = query.get('session_id');
650
+ if (sessionIdRaw !== null && sessionIdRaw.length > 256) {
651
+ throw new HttpError(400, 'session_id exceeds 256-character cap');
652
+ }
653
+ const sessionId = sessionIdRaw && sessionIdRaw.trim().length > 0
654
+ ? sessionIdRaw.trim()
655
+ : undefined;
656
+ // A7 recall-trace: opt-in explain flag. When set, api.recall attaches the
657
+ // lifecycle re-ranking trace (goal-boost step on the api pipeline) +
658
+ // rerankPipeline:'api' to each result item; the field then rides on the
659
+ // serialized RecallResult. Mirrors the include_continuity convention.
660
+ const explainRaw = query.get('explain');
661
+ const explain = explainRaw === '1' || explainRaw === 'true';
662
+ const ctx = await buildContextWithAuth(req, opts);
663
+ // v0.33 / J1 — HTTP per-pipeline anchoring detector. HTTP threads its
664
+ // ring snapshot via opts.recallHistory so api.recall's own
665
+ // anchoringHint compute path activates. Unlike CLI (which computes
666
+ // its own hint separately because cmdRecall runs its own physics/
667
+ // hybrid pipeline outside api.recall), HTTP's /v1/memories response
668
+ // body IS api.recall's result directly. So the api.recall-computed
669
+ // hint flows through. HIPPO_ANCHORING=off short-circuits.
670
+ let httpRecallHistory;
671
+ let httpRingKey;
672
+ if (biasHintEnabled('anchoring')) {
673
+ if (sessionId) {
674
+ // Codex round-5 P2 catch: do NOT mutate sessionRecallHistoryHttp
675
+ // before recall() preflight runs. A request with an invalid
676
+ // scorer_window / fresh_tail_count would create-or-touch the
677
+ // session ring (LRU-evicting valid sessions) even though recall
678
+ // throws 400. Snapshot the EXISTING ring if present; only
679
+ // create-or-touch after the recall returns successfully.
680
+ httpRingKey = buildSessionKey(ctx.tenantId, sessionId);
681
+ const existingRing = sessionRecallHistoryHttp.get(httpRingKey);
682
+ httpRecallHistory = existingRing ? snapshotRing(existingRing) : [];
629
683
  }
630
684
  else {
631
- sendJson(res, 200, { ok: true });
632
- }
633
- return;
685
+ // Telemetry: caller had no session_id so ring tracking skipped.
686
+ // Per the normal recall-audit convention (api.ts:854 stores
687
+ // SHA-256/16 hash of the query, NOT raw text), avoid retaining
688
+ // prompts in audit_log here too — query content can contain
689
+ // secrets, PII, or RTBF-restricted material. Codex round-2 P2
690
+ // catch: hashQueryText is a 32-bit FNV-1a designed for recall
691
+ // matching, NOT a privacy hash; brute-force trivial for low-
692
+ // entropy queries. Use the same SHA-256/16 truncation as the
693
+ // canonical recall audit.
694
+ const dbForAudit = openHippoDb(opts.hippoRoot);
695
+ try {
696
+ appendAuditEvent(dbForAudit, {
697
+ tenantId: ctx.tenantId,
698
+ actor: ctx.actor.subject,
699
+ op: 'recall_anchor_skipped_no_session',
700
+ targetId: undefined,
701
+ metadata: auditQueryFields(q),
702
+ });
703
+ }
704
+ finally {
705
+ closeHippoDb(dbForAudit);
706
+ }
707
+ }
708
+ }
709
+ const recallExtra = {};
710
+ if (freshTailCount !== undefined)
711
+ recallExtra.freshTailCount = freshTailCount;
712
+ if (freshTailSessionId !== undefined)
713
+ recallExtra.freshTailSessionId = freshTailSessionId;
714
+ if (summarizeOverflow !== undefined)
715
+ recallExtra.summarizeOverflow = summarizeOverflow;
716
+ if (scorerWindow !== undefined)
717
+ recallExtra.scorerWindow = scorerWindow;
718
+ if (sessionId !== undefined)
719
+ recallExtra.sessionId = sessionId;
720
+ if (httpRecallHistory !== undefined)
721
+ recallExtra.recallHistory = httpRecallHistory;
722
+ if (explain)
723
+ recallExtra.explain = explain;
724
+ const result = await retrieve(ctx, {
725
+ query: q,
726
+ limit,
727
+ mode: mode ?? undefined,
728
+ scope: scope ?? undefined,
729
+ includeContinuity,
730
+ ...recallExtra,
731
+ });
732
+ // v0.33 / J1 — append AFTER recall completes (snapshot was taken before
733
+ // recall() ran). anchoredOn carries the memoryId of any hint that fired
734
+ // (api.recall computed it from the same snapshot we passed in), feeding
735
+ // the cooldown logic for the NEXT recall on this session.
736
+ // Codex round-5 P2 fix: create-or-touch the ring ONLY HERE, after recall
737
+ // returns successfully. Invalid requests that throw 400 in recall()
738
+ // never reach this point, so they cannot LRU-evict valid sessions.
739
+ if (httpRingKey) {
740
+ const httpRing = getOrCreateRing(sessionRecallHistoryHttp, httpRingKey);
741
+ const topId = result.results[0]?.id ?? null;
742
+ appendRecall(httpRing, hashQueryText(q), topId, result.anchoringHint?.memoryId);
743
+ }
744
+ // Each recall surface counts its own hits; api.recall is no chokepoint,
745
+ // since the CLI never calls it and MCP shows the user a different band.
746
+ updateStats(opts.hippoRoot, { recalled: result.results.length });
747
+ // Continuity payloads should never be cached. The caller is asking for
748
+ // session-state-aware data; intermediaries must not reuse it across users.
749
+ if (includeContinuity) {
750
+ res.setHeader('Cache-Control', 'no-store');
751
+ }
752
+ recordTokens(ctx, 'http_recall', { items: result.results.length, tokens: result.tokens + (result.continuityTokens ?? 0), sessionId: sessionId ?? null });
753
+ sendJson(res, 200, result);
754
+ return;
755
+ }
756
+ // GET /v1/sessions/:id/assemble?budget=N&freshTail=N&summarizeOlder=0|1
757
+ // Phase 2 context-engine API. Returns ordered AssembledContextItem[]
758
+ // with fresh-tail raws + summary substitutions + bio-aware budget fit.
759
+ // Tenant scope from Bearer; default-deny on private rows.
760
+ async function handleAssembleSession({ req, res, opts, query }, assembleMatch) {
761
+ validateIdSegment(assembleMatch.id, 'session id');
762
+ const budgetRaw = query.get('budget');
763
+ const budget = budgetRaw === null ? undefined : Number(budgetRaw);
764
+ if (budget !== undefined && (!Number.isFinite(budget) || budget <= 0)) {
765
+ throw new HttpError(400, 'budget must be a positive number');
766
+ }
767
+ const ftRaw = query.get('freshTail');
768
+ const freshTailCount = ftRaw === null ? undefined : Number(ftRaw);
769
+ if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
770
+ throw new HttpError(400, 'freshTail must be a non-negative number');
771
+ }
772
+ // v1.6.3 senior review P1: same strict-parse convention as the v1.6.3
773
+ // summarize_overflow tighten on /v1/memories. Pre-v1.6.3 accepted any
774
+ // non-'0'/'false' as true; ?summarizeOlder=banana now correctly returns
775
+ // false (matches includeContinuity convention).
776
+ const sumOlderRaw = query.get('summarizeOlder');
777
+ const summarizeOlder = sumOlderRaw === null
778
+ ? undefined
779
+ : (sumOlderRaw === '1' || sumOlderRaw === 'true');
780
+ const scopeQ = query.get('scope');
781
+ const scope = scopeQ !== null && scopeQ.length > 0 ? scopeQ : undefined;
782
+ const ctx = await buildContextWithAuth(req, opts);
783
+ const assembleExtra = {};
784
+ if (budget !== undefined)
785
+ assembleExtra.budget = budget;
786
+ if (freshTailCount !== undefined)
787
+ assembleExtra.freshTailCount = freshTailCount;
788
+ if (summarizeOlder !== undefined)
789
+ assembleExtra.summarizeOlder = summarizeOlder;
790
+ if (scope !== undefined)
791
+ assembleExtra.scope = scope;
792
+ const result = assemble(ctx, assembleMatch.id, { ...assembleExtra, cost: assembleCost(assembleMatch.id) });
793
+ recordTokens(ctx, 'http_assemble', { items: result.items.length, tokens: result.tokens, sessionId: assembleMatch.id });
794
+ sendJson(res, 200, result);
795
+ return;
796
+ }
797
+ // GET /v1/recall/drill/:id?limit=N&budget=N
798
+ // Companion to /v1/memories. When recall surfaces a level-2 summary in
799
+ // place of overflowed children (RecallResultItem.isSummary === true), the
800
+ // caller drills into the summary id to recover the originals. Tenant
801
+ // scoped via Bearer; default-deny on private scopes for both summary
802
+ // and children.
803
+ async function handleDrillRecall({ req, res, opts, query }, drillMatch) {
804
+ validateIdSegment(drillMatch.id, 'summary id');
805
+ const limitRaw = query.get('limit');
806
+ const limit = limitRaw === null ? undefined : Number(limitRaw);
807
+ if (limit !== undefined && (!Number.isFinite(limit) || limit <= 0)) {
808
+ throw new HttpError(400, 'limit must be a positive number');
809
+ }
810
+ const budgetRaw = query.get('budget');
811
+ const budget = budgetRaw === null ? undefined : Number(budgetRaw);
812
+ if (budget !== undefined && (!Number.isFinite(budget) || budget <= 0)) {
813
+ throw new HttpError(400, 'budget must be a positive number');
814
+ }
815
+ // v0.30 / E5: depth query param walks N levels (default 1, hard cap 10).
816
+ const depthRaw = query.get('depth');
817
+ let depth;
818
+ if (depthRaw !== null) {
819
+ const parsed = Number(depthRaw);
820
+ // L4 fold: reject out-of-range explicitly (no silent clamp).
821
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10) {
822
+ throw new HttpError(400, 'depth must be a positive integer between 1 and 10');
823
+ }
824
+ depth = parsed;
825
+ }
826
+ const ctx = await buildContextWithAuth(req, opts);
827
+ const drillExtra = {};
828
+ if (limit !== undefined)
829
+ drillExtra.limit = limit;
830
+ if (budget !== undefined)
831
+ drillExtra.budget = budget;
832
+ if (depth !== undefined)
833
+ drillExtra.depth = depth;
834
+ const result = drillDown(ctx, drillMatch.id, { ...drillExtra, cost: drillCost });
835
+ if ('failure' in result) {
836
+ // v1.6.4: leaf id maps to 422 (caller-actionable). Other cases stay
837
+ // as 404 to avoid leaking cross-tenant existence or scope grants.
838
+ if (result.failure === 'not_drillable') {
839
+ throw new HttpError(422, 'Id is a leaf row, not a level-2+ summary; nothing to drill into');
840
+ }
841
+ throw new HttpError(404, 'No drillable summary at this id');
842
+ }
843
+ sendJson(res, 200, result);
844
+ return;
845
+ }
846
+ // /v1/memories/:id/* and DELETE /v1/memories/:id
847
+ async function handleArchiveMemory({ req, res, opts }, archiveMatch) {
848
+ validateIdSegment(archiveMatch.id, 'memory id');
849
+ const body = await parseJsonBody(req);
850
+ const reason = getString(body, 'reason');
851
+ if (!reason) {
852
+ throw new HttpError(400, 'reason is required');
853
+ }
854
+ const ctx = await buildContextWithAuth(req, opts);
855
+ const result = archiveRaw(ctx, archiveMatch.id, reason);
856
+ sendJson(res, 200, result);
857
+ return;
858
+ }
859
+ async function handleSupersedeMemory({ req, res, opts }, supersedeMatch) {
860
+ validateIdSegment(supersedeMatch.id, 'memory id');
861
+ const body = await parseJsonBody(req);
862
+ const content = getString(body, 'content');
863
+ if (!content) {
864
+ throw new HttpError(400, 'content is required');
865
+ }
866
+ const ctx = await buildContextWithAuth(req, opts);
867
+ const result = supersede(ctx, supersedeMatch.id, content);
868
+ sendJson(res, 200, result);
869
+ return;
870
+ }
871
+ async function handlePromoteMemory({ req, res, opts }, promoteMatch) {
872
+ validateIdSegment(promoteMatch.id, 'memory id');
873
+ const ctx = await buildContextWithAuth(req, opts);
874
+ const result = promote(ctx, promoteMatch.id);
875
+ sendJson(res, 200, result);
876
+ return;
877
+ }
878
+ async function handleForgetMemory({ req, res, opts }, idMatch) {
879
+ validateIdSegment(idMatch.id, 'memory id');
880
+ const ctx = await buildContextWithAuth(req, opts);
881
+ const result = forget(ctx, idMatch.id);
882
+ sendJson(res, 200, result);
883
+ return;
884
+ }
885
+ // POST /v1/outcome — apply a positive/negative outcome to memory ids.
886
+ // Body: {ids?: string[], good: boolean}. If ids omitted, falls back to
887
+ // the last-recall path (api.outcomeForLastRecall); returned shape is
888
+ // {applied, ids} in that case so callers can disambiguate "no recent
889
+ // recall" from "all ids skipped". Each applied id writes one audit_log
890
+ // row (op='outcome', actor from Bearer).
891
+ async function handleApplyOutcome({ req, res, opts }) {
892
+ const body = await parseJsonBody(req);
893
+ const good = body['good'];
894
+ if (!isJsonBoolean(good)) {
895
+ throw new HttpError(400, 'good is required (boolean)');
896
+ }
897
+ const idsRaw = body['ids'];
898
+ let ids;
899
+ if (idsRaw !== undefined) {
900
+ if (!Array.isArray(idsRaw)) {
901
+ throw new HttpError(400, 'ids must be an array of non-empty strings');
902
+ }
903
+ const isNonEmptyId = (item) => isJsonString(item) && item.length > 0;
904
+ if (!idsRaw.every(isNonEmptyId)) {
905
+ throw new HttpError(400, 'ids must be an array of non-empty strings');
906
+ }
907
+ // v1.11.5: DoS cap on ids.length. Each id triggers ~3 DB ops (readEntry +
908
+ // writeEntry + appendAuditEvent). N=1000 keeps per-request work bounded
909
+ // to sub-second wall time on SQLite hot path. Cap BEFORE buildContextWithAuth
910
+ // so attack traffic doesn't pay the api-key lookup cost.
911
+ if (idsRaw.length > 1000) {
912
+ throw new HttpError(400, 'ids exceeds 1000-id cap');
913
+ }
914
+ ids = idsRaw;
915
+ }
916
+ const ctx = await buildContextWithAuth(req, opts);
917
+ if (ids !== undefined) {
918
+ const { applied } = outcome(ctx, ids, good);
919
+ sendJson(res, 200, { applied });
920
+ }
921
+ else {
922
+ const result = outcomeForLastRecall(ctx, good);
923
+ sendJson(res, 200, result);
634
924
  }
635
- // E3: per-IP rate limit on /v1/* and /mcp* to bound api-key-id enumeration. /health
636
- // (a liveness probe) and other paths are never throttled. A 429 thrown
637
- // here lands in the createServer catch like any other HttpError.
638
- //
639
- // Keyed on the socket's remote address by default. Behind a TLS-terminating
640
- // proxy every socket carries the proxy's address, collapsing the per-IP
641
- // buckets into one global bucket that pre-auth traffic can drain; set
642
- // HIPPO_CLIENT_IP_HEADER there so each real client gets its own bucket
643
- // (see clientIpForRateLimit).
644
- if (limiter && (path.startsWith('/v1/') || path === '/mcp' || path === '/mcp/stream')) {
645
- const ip = clientIpForRateLimit(req);
646
- if (!limiter.check(ip)) {
647
- throw new HttpError(429, 'rate limit exceeded');
925
+ return;
926
+ }
927
+ // GET /v1/context — assemble a budget-bounded context bundle. Returns
928
+ // ContextResult JSON (entries + tokens + activeSnapshot + sessionHandoff
929
+ // + recentEvents). No server-side rendering; clients render. Tenant-scoped
930
+ // via the Bearer. Pinned-only + '*' fallback skip the recall audit emit
931
+ // (matches cmdContext); real-query hybrid search emits one 'recall' row.
932
+ async function handleGetContext({ req, res, opts, query }) {
933
+ const q = query.get('q') ?? undefined;
934
+ // v1.11.5: DoS cap on q-param length. 1024 covers real multi-clause queries
935
+ // (pasted error messages, multi-stem searches) while bounding BM25
936
+ // tokenisation cost (~150 tokens worst case at 1024 chars).
937
+ if (q !== undefined && q.length > 1024) {
938
+ throw new HttpError(400, 'q exceeds 1024-character cap');
939
+ }
940
+ const budgetRaw = query.get('budget');
941
+ let budget;
942
+ if (budgetRaw !== null) {
943
+ budget = Number(budgetRaw);
944
+ if (!Number.isFinite(budget) || budget < 0) {
945
+ throw new HttpError(400, 'budget must be a non-negative number');
946
+ }
947
+ }
948
+ const limitRaw = query.get('limit');
949
+ let limit;
950
+ if (limitRaw !== null) {
951
+ limit = Number(limitRaw);
952
+ if (!Number.isFinite(limit) || limit <= 0) {
953
+ throw new HttpError(400, 'limit must be a positive number');
648
954
  }
649
955
  }
650
- // POST /v1/memories
651
- if (method === 'POST' && path === '/v1/memories') {
652
- const body = await parseJsonBody(req);
653
- const content = getString(body, 'content');
654
- if (!content) {
655
- throw new HttpError(400, 'content is required');
956
+ const pinnedOnlyRaw = query.get('pinned_only');
957
+ const pinnedOnly = pinnedOnlyRaw === '1' || pinnedOnlyRaw === 'true';
958
+ const scopeRaw = query.get('scope');
959
+ if (scopeRaw !== null && scopeRaw.length > 256) {
960
+ throw new HttpError(400, 'scope exceeds 256-character cap');
961
+ }
962
+ const scope = scopeRaw === null ? undefined : scopeRaw;
963
+ const includeRecentRaw = query.get('include_recent');
964
+ let includeRecent;
965
+ if (includeRecentRaw !== null) {
966
+ includeRecent = Number(includeRecentRaw);
967
+ if (!Number.isFinite(includeRecent) || includeRecent < 0) {
968
+ throw new HttpError(400, 'include_recent must be a non-negative number');
969
+ }
970
+ }
971
+ // v39 memory scope isolation: cross_project=1|true re-includes
972
+ // other-project rows (tagged category 'cross-project' in the response).
973
+ // The partition identity comes from the SERVED STORE's location, not the
974
+ // daemon's process cwd - a daemon started from anywhere still isolates
975
+ // the project it serves.
976
+ const crossProjectRaw = query.get('cross_project');
977
+ const crossProject = crossProjectRaw === '1' || crossProjectRaw === 'true';
978
+ const ctx = await buildContextWithAuth(req, opts);
979
+ const result = await getContext(ctx, {
980
+ q,
981
+ budget,
982
+ limit,
983
+ pinnedOnly,
984
+ scope,
985
+ includeRecent,
986
+ crossProject,
987
+ currentProject: resolveProjectIdentity(dirname(resolve(opts.hippoRoot))).name,
988
+ cost: contextCost('markdown', 'observe'), // clients render; the budget prices the block `hippo context` would print
989
+ });
990
+ recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
991
+ sendJson(res, 200, result);
992
+ return;
993
+ }
994
+ // POST /v1/sleep — host-wide consolidation pipeline (consolidate + dedup +
995
+ // audit + share + ambient). serve() refuses non-loopback hosts at boot, AND
996
+ // this per-request loopback assertion makes the host-wide semantic fail-
997
+ // closed regardless of any future serve() boot-config change. Body:
998
+ // {dry_run?, no_share?}. Returns SleepResult JSON.
999
+ //
1000
+ // Tenant scope (Episode A follow-up tracked in TODOS.md): api.sleep operates
1001
+ // on the WHOLE hippoRoot (cross-tenant by design, matching CLI cmdSleep).
1002
+ // The loopback-only guard is the trust boundary today. Future non-loopback
1003
+ // serving must also zero the cross-tenant counters for other tenants
1004
+ // (D1 in docs/decisions/2026-05-24-blocked-items.md).
1005
+ async function handleSleep({ req, res, opts }) {
1006
+ // Defensive per-request loopback guard. Uses the canonical isLoopback()
1007
+ // helper above so any future extension (additional mapped/IPv6 forms,
1008
+ // NAT64 prefixes) flows through without drift. serve()'s boot-time host
1009
+ // check is the primary trust boundary; this is belt-and-suspenders.
1010
+ if (!isLoopback(req.socket.remoteAddress)) {
1011
+ throw new HttpError(403, '/v1/sleep is loopback-only (host-wide consolidation; see CHANGELOG v1.11.4)');
1012
+ }
1013
+ // v1.12.0 A5 v2 sub-1: admin-role gate. Forward-defensive — exists today
1014
+ // under loopback-only enforcement (loopback fallback is admin by default;
1015
+ // any Bearer-authed caller now carries an explicit role from the api_keys
1016
+ // row). When non-loopback serving lands, this gate is the actual auth
1017
+ // boundary on host-wide sleep.
1018
+ const sleepCtx = await buildContextWithAuth(req, opts);
1019
+ // Sleep consolidates every tenant under hippoRoot, so it is a cross-tenant action.
1020
+ assertCrossTenantAdmin(sleepCtx, '/v1/sleep');
1021
+ const body = await parseJsonBody(req);
1022
+ const dryRunRaw = body['dry_run'];
1023
+ if (dryRunRaw !== undefined && !isJsonBoolean(dryRunRaw)) {
1024
+ throw new HttpError(400, 'dry_run must be a boolean');
1025
+ }
1026
+ const noShareRaw = body['no_share'];
1027
+ if (noShareRaw !== undefined && !isJsonBoolean(noShareRaw)) {
1028
+ throw new HttpError(400, 'no_share must be a boolean');
1029
+ }
1030
+ // v1.12.0: sleepCtx already built above for the admin-role gate; reuse.
1031
+ const result = await sleep(sleepCtx, {
1032
+ dryRun: dryRunRaw === true,
1033
+ noShare: noShareRaw === true,
1034
+ });
1035
+ sendJson(res, 200, result);
1036
+ return;
1037
+ }
1038
+ // POST /v1/auth/keys — mint a new API key. Plaintext lands in the response
1039
+ // body (Task 8): the HTTP layer hands it to the client; the user-facing
1040
+ // "store this somewhere safe" warning belongs in the CLI client, not here.
1041
+ async function handleCreateAuthKey({ req, res, opts }) {
1042
+ const body = await parseJsonBody(req);
1043
+ const labelRaw = body['label'];
1044
+ if (labelRaw !== undefined && !isJsonString(labelRaw)) {
1045
+ throw new HttpError(400, 'label must be a string');
1046
+ }
1047
+ // v1.12.3: optional body.role mirrors the --role CLI flag. Validated
1048
+ // strictly — anything other than 'admin'|'member' is a 400 (no silent
1049
+ // fallback to admin). authCreate refuses a member caller with a 403.
1050
+ const roleRaw = body['role'];
1051
+ let role;
1052
+ if (roleRaw !== undefined) {
1053
+ if (roleRaw !== 'admin' && roleRaw !== 'member') {
1054
+ throw new HttpError(400, "role must be 'admin' or 'member'");
1055
+ }
1056
+ role = roleRaw;
1057
+ }
1058
+ // Security: any `tenantId` in the body is IGNORED. The minted key is
1059
+ // bound to the caller's authenticated tenant (ctx.tenantId, resolved
1060
+ // from the Bearer token). Forwarding body.tenantId here would let
1061
+ // tenant A mint a key for tenant B — see authCreate doc comment.
1062
+ const ctx = await buildContextWithAuth(req, opts);
1063
+ const result = authCreate(ctx, {
1064
+ label: labelRaw,
1065
+ role,
1066
+ });
1067
+ sendJson(res, 200, result);
1068
+ return;
1069
+ }
1070
+ // GET /v1/auth/keys?active=true — list keys visible to ctx.tenantId.
1071
+ // `active` defaults to true so the common case (show me usable keys) is
1072
+ // a single GET; ?active=false includes revoked rows.
1073
+ async function handleListAuthKeys({ req, res, opts, query }) {
1074
+ const activeRaw = query.get('active');
1075
+ let active = true;
1076
+ if (activeRaw !== null) {
1077
+ if (activeRaw === 'true')
1078
+ active = true;
1079
+ else if (activeRaw === 'false')
1080
+ active = false;
1081
+ else
1082
+ throw new HttpError(400, "active must be 'true' or 'false'");
1083
+ }
1084
+ const ctx = await buildContextWithAuth(req, opts);
1085
+ const result = authList(ctx, { active });
1086
+ sendJson(res, 200, result);
1087
+ return;
1088
+ }
1089
+ // DELETE /v1/auth/keys/:keyId — revoke. Missing or cross-tenant keys are 404
1090
+ // (no info leak); a member key targeting any key but its own is 403.
1091
+ // 200 with the body rather than 204 so the caller sees revokedAt.
1092
+ async function handleRevokeAuthKey({ req, res, opts }, keyMatch) {
1093
+ validateIdSegment(keyMatch.keyId, 'key id');
1094
+ const ctx = await buildContextWithAuth(req, opts);
1095
+ const result = authRevoke(ctx, keyMatch.keyId);
1096
+ sendJson(res, 200, result);
1097
+ return;
1098
+ }
1099
+ // GET /v1/quarantine?status=: CD5 review queue. quarantineList carries no role gate itself, so it's checked here.
1100
+ async function handleListQuarantine({ req, res, opts, query }) {
1101
+ const ctx = await buildContextWithAuth(req, opts);
1102
+ if (ctx.actor.role !== 'admin') {
1103
+ throw new HttpError(403, '/v1/quarantine requires admin role');
1104
+ }
1105
+ const statusRaw = query.get('status');
1106
+ let status = 'pending';
1107
+ if (statusRaw !== null) {
1108
+ if (statusRaw !== 'pending' && statusRaw !== 'approved' && statusRaw !== 'rejected' && statusRaw !== 'all') {
1109
+ throw new HttpError(400, 'status must be one of: pending | approved | rejected | all');
1110
+ }
1111
+ status = statusRaw;
1112
+ }
1113
+ sendJson(res, 200, { quarantine: quarantineList(ctx, { status }) });
1114
+ return;
1115
+ }
1116
+ // POST /v1/quarantine/:id/approve: admin only; ForbiddenError falls through to mapApiError's 403.
1117
+ async function handleApproveQuarantine({ req, res, opts }, quarantineApproveMatch) {
1118
+ validateIdSegment(quarantineApproveMatch.id, 'memory id');
1119
+ const ctx = await buildContextWithAuth(req, opts);
1120
+ try {
1121
+ quarantineApprove(ctx, quarantineApproveMatch.id);
1122
+ sendJson(res, 200, { approved: quarantineApproveMatch.id });
1123
+ }
1124
+ catch (e) {
1125
+ const msg = e instanceof Error ? e.message : String(e);
1126
+ if (msg.includes('not quarantined'))
1127
+ throw new HttpError(404, msg);
1128
+ if (msg.includes('is already') || msg.includes('scope changed'))
1129
+ throw new HttpError(409, msg);
1130
+ throw e;
1131
+ }
1132
+ return;
1133
+ }
1134
+ // POST /v1/quarantine/:id/reject: admin only; ForbiddenError falls through to mapApiError's 403.
1135
+ async function handleRejectQuarantine({ req, res, opts }, quarantineRejectMatch) {
1136
+ validateIdSegment(quarantineRejectMatch.id, 'memory id');
1137
+ const ctx = await buildContextWithAuth(req, opts);
1138
+ try {
1139
+ quarantineReject(ctx, quarantineRejectMatch.id);
1140
+ sendJson(res, 200, { rejected: quarantineRejectMatch.id });
1141
+ }
1142
+ catch (e) {
1143
+ const msg = e instanceof Error ? e.message : String(e);
1144
+ if (msg.includes('not quarantined'))
1145
+ throw new HttpError(404, msg);
1146
+ if (msg.includes('is already'))
1147
+ throw new HttpError(409, msg);
1148
+ throw e;
1149
+ }
1150
+ return;
1151
+ }
1152
+ // GET /v1/audit?op=&since=&limit= — read audit events. All three filters
1153
+ // validated at the route boundary so an invalid value lands a 400 before
1154
+ // we hit the DB.
1155
+ async function handleListAudit({ req, res, opts, query }) {
1156
+ const opRaw = query.get('op');
1157
+ let op;
1158
+ if (opRaw !== null) {
1159
+ if (!isSetMember(VALID_AUDIT_OPS, opRaw)) {
1160
+ throw new HttpError(400, `invalid op: ${opRaw}`);
1161
+ }
1162
+ op = opRaw;
1163
+ }
1164
+ const sinceRaw = query.get('since');
1165
+ let since;
1166
+ if (sinceRaw !== null) {
1167
+ const parsed = Date.parse(sinceRaw);
1168
+ if (!Number.isFinite(parsed)) {
1169
+ throw new HttpError(400, `invalid since: ${sinceRaw}`);
1170
+ }
1171
+ since = sinceRaw;
1172
+ }
1173
+ const limitRaw = query.get('limit');
1174
+ let limit;
1175
+ if (limitRaw !== null) {
1176
+ const parsed = Number(limitRaw);
1177
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > MAX_AUDIT_LIMIT) {
1178
+ throw new HttpError(400, `limit must be an integer between 1 and ${MAX_AUDIT_LIMIT}`);
1179
+ }
1180
+ limit = parsed;
1181
+ }
1182
+ const ctx = await buildContextWithAuth(req, opts);
1183
+ // ?tenant=<t> reads another tenant (e.g. '__host__' for consolidate rows); admin only.
1184
+ const tenantOverride = query.get('tenant');
1185
+ const crossTenant = tenantOverride !== null && tenantOverride !== '' && tenantOverride !== ctx.tenantId;
1186
+ if (crossTenant)
1187
+ assertCrossTenantAdmin(ctx, '/v1/audit?tenant= for another tenant');
1188
+ const effectiveCtx = crossTenant ? { ...ctx, tenantId: tenantOverride } : ctx;
1189
+ const result = auditList(effectiveCtx, { op, since, limit });
1190
+ sendJson(res, 200, result);
1191
+ return;
1192
+ }
1193
+ // ── E2 prediction first-class object (v0.31) ──
1194
+ // docs/plans/2026-05-26-e2-prediction-object.md
1195
+ //
1196
+ // 4 routes: POST /v1/predictions (create), GET /v1/predictions (list),
1197
+ // GET /v1/predictions/:id (show), POST /v1/predictions/:id/close (close).
1198
+ // All Bearer-authed + tenant-scoped via buildContextWithAuth. closure_state
1199
+ // validated against VALID_CLOSURE_STATES (3 states). DoS caps on claim
1200
+ // (4096 chars) + closureNote (2048 chars) per v1.11.4 pattern.
1201
+ async function handleCreatePrediction({ req, res, opts }) {
1202
+ const body = await parseJsonBody(req);
1203
+ const claim = body['claim'];
1204
+ if (!isJsonString(claim) || claim.length === 0) {
1205
+ throw new HttpError(400, 'claim is required (non-empty string)');
1206
+ }
1207
+ if (claim.length > 4096) {
1208
+ throw new HttpError(400, 'claim exceeds 4096-character cap');
1209
+ }
1210
+ const classTag = body['classTag'];
1211
+ if (!isJsonString(classTag) || classTag.length === 0) {
1212
+ throw new HttpError(400, 'classTag is required (non-empty string)');
1213
+ }
1214
+ const estimate = body['estimate'];
1215
+ let estimateValue;
1216
+ if (estimate !== undefined && estimate !== null) {
1217
+ if (!isJsonNumber(estimate) || !Number.isFinite(estimate)) {
1218
+ throw new HttpError(400, 'estimate must be a finite number');
1219
+ }
1220
+ estimateValue = estimate;
1221
+ }
1222
+ const unit = body['unit'];
1223
+ let estimateUnit;
1224
+ if (unit !== undefined && unit !== null) {
1225
+ if (!isJsonString(unit)) {
1226
+ throw new HttpError(400, 'unit must be a string');
1227
+ }
1228
+ estimateUnit = unit;
1229
+ }
1230
+ const targetDate = body['targetDate'];
1231
+ let targetDateValue;
1232
+ if (targetDate !== undefined && targetDate !== null) {
1233
+ if (!isJsonString(targetDate)) {
1234
+ throw new HttpError(400, 'targetDate must be an ISO date string');
1235
+ }
1236
+ targetDateValue = targetDate;
1237
+ }
1238
+ const ctx = await buildContextWithAuth(req, opts);
1239
+ const prediction = savePrediction(opts.hippoRoot, ctx.tenantId, {
1240
+ classTag,
1241
+ claimText: claim,
1242
+ estimateValue,
1243
+ estimateUnit,
1244
+ targetDate: targetDateValue,
1245
+ }, ctx.actor.subject);
1246
+ sendJson(res, 201, { prediction });
1247
+ return;
1248
+ }
1249
+ async function handleListPredictions({ req, res, opts, query }) {
1250
+ const classTag = query.get('class') ?? undefined;
1251
+ const status = query.get('status') ?? 'all';
1252
+ const limit = parseListLimit(query.get('limit'));
1253
+ const ctx = await buildContextWithAuth(req, opts);
1254
+ let predictions;
1255
+ if (status === 'all') {
1256
+ if (classTag) {
1257
+ predictions = loadPredictionsByClass(opts.hippoRoot, ctx.tenantId, classTag, { limit });
656
1258
  }
657
- const kindRaw = getString(body, 'kind');
658
- if (kindRaw !== undefined && !isSetMember(VALID_KINDS, kindRaw)) {
659
- throw new HttpError(400, `invalid kind: ${kindRaw}`);
1259
+ else {
1260
+ predictions = loadOpenPredictions(opts.hippoRoot, ctx.tenantId, { limit });
660
1261
  }
661
- const ctx = await buildContextWithAuth(req, opts);
662
- const result = remember(ctx, {
663
- content,
664
- kind: kindRaw,
665
- scope: getString(body, 'scope'),
666
- owner: getString(body, 'owner'),
667
- artifactRef: getString(body, 'artifactRef'),
668
- tags: getStringArray(body, 'tags'),
669
- });
670
- sendJson(res, 200, result);
671
- return;
672
1262
  }
673
- // GET /v1/graph?entity=NAME&limit=N — read-only entity/relation graph (tenant-scoped)
674
- if (method === 'GET' && path === '/v1/graph') {
675
- const entityRaw = query.get('entity');
676
- // Cap at the graph entity-name cap (512), not the id-shaped 256, so a valid
677
- // long decision/policy name remains focusable over HTTP (codex P2).
678
- if (entityRaw !== null && entityRaw.length > MAX_ENTITY_NAME_LEN) {
679
- throw new HttpError(400, `entity exceeds the ${MAX_ENTITY_NAME_LEN}-character cap`);
680
- }
681
- const limit = parseListLimit(query.get('limit'));
682
- const ctx = await buildContextWithAuth(req, opts);
683
- const model = buildGraphModel(ctx.hippoRoot, ctx.tenantId, {
684
- entity: entityRaw ?? undefined,
1263
+ else if (status === 'open') {
1264
+ predictions = loadOpenPredictions(opts.hippoRoot, ctx.tenantId, {
1265
+ classTag: classTag || undefined,
685
1266
  limit,
686
1267
  });
687
- sendJson(res, 200, model);
688
- return;
689
1268
  }
690
- // GET /v1/memories?q=...&limit=...&mode=...&scope=...&include_continuity=1
691
- if (method === 'GET' && path === '/v1/memories') {
692
- const q = query.get('q');
693
- if (!q) {
694
- throw new HttpError(400, 'q is required');
695
- }
696
- const limitRaw = query.get('limit');
697
- const limit = limitRaw === null ? undefined : parseListLimit(limitRaw);
698
- const mode = query.get('mode');
699
- if (mode !== null && mode !== 'bm25' && mode !== 'hybrid' && mode !== 'physics') {
700
- throw new HttpError(400, "mode must be 'bm25', 'hybrid', or 'physics'");
701
- }
702
- const scope = query.get('scope');
703
- const includeContinuityRaw = query.get('include_continuity');
704
- const includeContinuity = includeContinuityRaw === '1'
705
- || includeContinuityRaw === 'true';
706
- // v1.6.2: surface the v1.5.0/v1.5.2 RecallOpts additions to HTTP
707
- // callers. Pre-v1.6.2 the route silently ignored these so the
708
- // session-scoped fresh-tail and summary substitution were JS-only.
709
- const freshTailCountRaw = query.get('fresh_tail_count');
710
- const freshTailCount = freshTailCountRaw === null ? undefined : Number(freshTailCountRaw);
711
- if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
712
- throw new HttpError(400, 'fresh_tail_count must be a non-negative number');
713
- }
714
- // v1.6.3 senior-review P1-3: cap session_id length consistent with the
715
- // rest of the API. Untrimmed strings round-trip through the SQL layer
716
- // and through any downstream metric/log; 256 is generous for a session
717
- // id and matches the rest of this file's id-shaped param parsers.
718
- const freshTailSessionIdRaw = query.get('fresh_tail_session_id');
719
- if (freshTailSessionIdRaw !== null && freshTailSessionIdRaw.length > 256) {
720
- throw new HttpError(400, 'fresh_tail_session_id exceeds 256-character cap');
721
- }
722
- const freshTailSessionId = freshTailSessionIdRaw && freshTailSessionIdRaw.length > 0
723
- ? freshTailSessionIdRaw
724
- : undefined;
725
- // v1.6.3 senior-review P1-4: tighten parser to match the includeContinuity
726
- // convention. Pre-v1.6.3 accepted any non-'0'/'false' value as `true`,
727
- // so `?summarize_overflow=banana` and `?summarize_overflow=` both
728
- // turned it on. Surface convention drift fixed.
729
- const summarizeOverflowRaw = query.get('summarize_overflow');
730
- const summarizeOverflow = summarizeOverflowRaw === null
731
- ? undefined
732
- : (summarizeOverflowRaw === '1' || summarizeOverflowRaw === 'true');
733
- // recall() owns the shape rule (NaN, 0 and negatives throw invalid_scorer_window); the transport caps remote cost.
734
- const scorerWindowRaw = query.get('scorer_window');
735
- const scorerWindow = scorerWindowRaw === null ? undefined : Number(scorerWindowRaw);
736
- if (scorerWindow !== undefined && scorerWindow > 1000) {
737
- throw new HttpError(400, 'scorer_window must be <= 1000');
738
- }
739
- // v1.7.4: session_id for the dlPFC goal-stack boost. 256-char cap mirrors
740
- // fresh_tail_session_id (above). Trim then drop if empty so api.recall
741
- // sees undefined when the param is omitted or whitespace-only.
742
- const sessionIdRaw = query.get('session_id');
743
- if (sessionIdRaw !== null && sessionIdRaw.length > 256) {
744
- throw new HttpError(400, 'session_id exceeds 256-character cap');
745
- }
746
- const sessionId = sessionIdRaw && sessionIdRaw.trim().length > 0
747
- ? sessionIdRaw.trim()
748
- : undefined;
749
- // A7 recall-trace: opt-in explain flag. When set, api.recall attaches the
750
- // lifecycle re-ranking trace (goal-boost step on the api pipeline) +
751
- // rerankPipeline:'api' to each result item; the field then rides on the
752
- // serialized RecallResult. Mirrors the include_continuity convention.
753
- const explainRaw = query.get('explain');
754
- const explain = explainRaw === '1' || explainRaw === 'true';
755
- const ctx = await buildContextWithAuth(req, opts);
756
- // v0.33 / J1 — HTTP per-pipeline anchoring detector. HTTP threads its
757
- // ring snapshot via opts.recallHistory so api.recall's own
758
- // anchoringHint compute path activates. Unlike CLI (which computes
759
- // its own hint separately because cmdRecall runs its own physics/
760
- // hybrid pipeline outside api.recall), HTTP's /v1/memories response
761
- // body IS api.recall's result directly. So the api.recall-computed
762
- // hint flows through. HIPPO_ANCHORING=off short-circuits.
763
- let httpRecallHistory;
764
- let httpRingKey;
765
- if (process.env.HIPPO_ANCHORING !== 'off') {
766
- if (sessionId) {
767
- // Codex round-5 P2 catch: do NOT mutate sessionRecallHistoryHttp
768
- // before recall() preflight runs. A request with an invalid
769
- // scorer_window / fresh_tail_count would create-or-touch the
770
- // session ring (LRU-evicting valid sessions) even though recall
771
- // throws 400. Snapshot the EXISTING ring if present; only
772
- // create-or-touch after the recall returns successfully.
773
- httpRingKey = buildSessionKey(ctx.tenantId, sessionId);
774
- const existingRing = sessionRecallHistoryHttp.get(httpRingKey);
775
- httpRecallHistory = existingRing ? snapshotRing(existingRing) : [];
776
- }
777
- else {
778
- // Telemetry: caller had no session_id so ring tracking skipped.
779
- // Per the normal recall-audit convention (api.ts:854 stores
780
- // SHA-256/16 hash of the query, NOT raw text), avoid retaining
781
- // prompts in audit_log here too — query content can contain
782
- // secrets, PII, or RTBF-restricted material. Codex round-2 P2
783
- // catch: hashQueryText is a 32-bit FNV-1a designed for recall
784
- // matching, NOT a privacy hash; brute-force trivial for low-
785
- // entropy queries. Use the same SHA-256/16 truncation as the
786
- // canonical recall audit.
787
- const dbForAudit = openHippoDb(opts.hippoRoot);
788
- try {
789
- appendAuditEvent(dbForAudit, {
790
- tenantId: ctx.tenantId,
791
- actor: ctx.actor.subject,
792
- op: 'recall_anchor_skipped_no_session',
793
- targetId: undefined,
794
- metadata: auditQueryFields(q),
795
- });
796
- }
797
- finally {
798
- closeHippoDb(dbForAudit);
799
- }
800
- }
1269
+ else {
1270
+ if (!isSetMember(VALID_CLOSURE_STATES, status)) {
1271
+ throw new HttpError(400, `status must be one of: open | closed | closed-unknown | all (got "${status}")`);
801
1272
  }
802
- const recallExtra = {};
803
- if (freshTailCount !== undefined)
804
- recallExtra.freshTailCount = freshTailCount;
805
- if (freshTailSessionId !== undefined)
806
- recallExtra.freshTailSessionId = freshTailSessionId;
807
- if (summarizeOverflow !== undefined)
808
- recallExtra.summarizeOverflow = summarizeOverflow;
809
- if (scorerWindow !== undefined)
810
- recallExtra.scorerWindow = scorerWindow;
811
- if (sessionId !== undefined)
812
- recallExtra.sessionId = sessionId;
813
- if (httpRecallHistory !== undefined)
814
- recallExtra.recallHistory = httpRecallHistory;
815
- if (explain)
816
- recallExtra.explain = explain;
817
- const result = await retrieve(ctx, {
818
- query: q,
1273
+ if (!classTag) {
1274
+ throw new HttpError(400, 'status filter (non-open) requires class param');
1275
+ }
1276
+ predictions = loadPredictionsByClass(opts.hippoRoot, ctx.tenantId, classTag, {
1277
+ closureState: status,
819
1278
  limit,
820
- mode: mode ?? undefined,
821
- scope: scope ?? undefined,
822
- includeContinuity,
823
- ...recallExtra,
824
1279
  });
825
- // v0.33 / J1 — append AFTER recall completes (snapshot was taken before
826
- // recall() ran). anchoredOn carries the memoryId of any hint that fired
827
- // (api.recall computed it from the same snapshot we passed in), feeding
828
- // the cooldown logic for the NEXT recall on this session.
829
- // Codex round-5 P2 fix: create-or-touch the ring ONLY HERE, after recall
830
- // returns successfully. Invalid requests that throw 400 in recall()
831
- // never reach this point, so they cannot LRU-evict valid sessions.
832
- if (httpRingKey) {
833
- const httpRing = getOrCreateRing(sessionRecallHistoryHttp, httpRingKey);
834
- const topId = result.results[0]?.id ?? null;
835
- appendRecall(httpRing, hashQueryText(q), topId, result.anchoringHint?.memoryId);
836
- }
837
- // Each recall surface counts its own hits; api.recall is no chokepoint,
838
- // since the CLI never calls it and MCP shows the user a different band.
839
- updateStats(opts.hippoRoot, { recalled: result.results.length });
840
- // Continuity payloads should never be cached. The caller is asking for
841
- // session-state-aware data; intermediaries must not reuse it across users.
842
- if (includeContinuity) {
843
- res.setHeader('Cache-Control', 'no-store');
844
- }
845
- recordTokens(ctx, 'http_recall', { items: result.results.length, tokens: result.tokens + (result.continuityTokens ?? 0), sessionId: sessionId ?? null });
846
- sendJson(res, 200, result);
847
- return;
848
1280
  }
849
- // GET /v1/sessions/:id/assemble?budget=N&freshTail=N&summarizeOlder=0|1
850
- // Phase 2 context-engine API. Returns ordered AssembledContextItem[]
851
- // with fresh-tail raws + summary substitutions + bio-aware budget fit.
852
- // Tenant scope from Bearer; default-deny on private rows.
853
- const assembleMatch = matchPath('/v1/sessions/:id/assemble', path);
854
- if (method === 'GET' && assembleMatch) {
855
- validateIdSegment(assembleMatch.id, 'session id');
856
- const budgetRaw = query.get('budget');
857
- const budget = budgetRaw === null ? undefined : Number(budgetRaw);
858
- if (budget !== undefined && (!Number.isFinite(budget) || budget <= 0)) {
859
- throw new HttpError(400, 'budget must be a positive number');
860
- }
861
- const ftRaw = query.get('freshTail');
862
- const freshTailCount = ftRaw === null ? undefined : Number(ftRaw);
863
- if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
864
- throw new HttpError(400, 'freshTail must be a non-negative number');
865
- }
866
- // v1.6.3 senior review P1: same strict-parse convention as the v1.6.3
867
- // summarize_overflow tighten on /v1/memories. Pre-v1.6.3 accepted any
868
- // non-'0'/'false' as true; ?summarizeOlder=banana now correctly returns
869
- // false (matches includeContinuity convention).
870
- const sumOlderRaw = query.get('summarizeOlder');
871
- const summarizeOlder = sumOlderRaw === null
872
- ? undefined
873
- : (sumOlderRaw === '1' || sumOlderRaw === 'true');
874
- const scopeQ = query.get('scope');
875
- const scope = scopeQ !== null && scopeQ.length > 0 ? scopeQ : undefined;
876
- const ctx = await buildContextWithAuth(req, opts);
877
- const assembleExtra = {};
878
- if (budget !== undefined)
879
- assembleExtra.budget = budget;
880
- if (freshTailCount !== undefined)
881
- assembleExtra.freshTailCount = freshTailCount;
882
- if (summarizeOlder !== undefined)
883
- assembleExtra.summarizeOlder = summarizeOlder;
884
- if (scope !== undefined)
885
- assembleExtra.scope = scope;
886
- const result = assemble(ctx, assembleMatch.id, { ...assembleExtra, cost: assembleCost(assembleMatch.id) });
887
- recordTokens(ctx, 'http_assemble', { items: result.items.length, tokens: result.tokens, sessionId: assembleMatch.id });
888
- sendJson(res, 200, result);
889
- return;
1281
+ sendJson(res, 200, { predictions });
1282
+ return;
1283
+ }
1284
+ // J3 reference-class / planning-fallacy detector (v0.31).
1285
+ // Order matters: this must match BEFORE /v1/predictions/:id since 'stats'
1286
+ // is not a number — the :id regex requires \d+ so they don't conflict,
1287
+ // but routing this first avoids the dispatch order risk.
1288
+ async function handlePredictionStats({ req, res, opts, query }) {
1289
+ const classTag = query.get('class');
1290
+ if (!classTag || classTag.length === 0) {
1291
+ throw new HttpError(400, 'class param is required');
1292
+ }
1293
+ if (classTag.length > 256) {
1294
+ throw new HttpError(400, 'class exceeds 256-character cap');
1295
+ }
1296
+ const ctx = await buildContextWithAuth(req, opts);
1297
+ const baserate = computePredictionBaserate(opts.hippoRoot, ctx.tenantId, classTag, ctx.actor.subject);
1298
+ sendJson(res, 200, { baserate });
1299
+ return;
1300
+ }
1301
+ async function handleGetPrediction({ req, res, opts }, predictionByIdMatch) {
1302
+ const id = parseInt(predictionByIdMatch[1], 10);
1303
+ const ctx = await buildContextWithAuth(req, opts);
1304
+ const prediction = loadPredictionById(opts.hippoRoot, ctx.tenantId, id);
1305
+ if (!prediction) {
1306
+ throw new HttpError(404, `prediction ${id} not found`);
1307
+ }
1308
+ sendJson(res, 200, { prediction });
1309
+ return;
1310
+ }
1311
+ async function handleClosePrediction({ req, res, opts }, predictionCloseMatch) {
1312
+ const id = parseInt(predictionCloseMatch[1], 10);
1313
+ const body = await parseJsonBody(req);
1314
+ const state = body['state'];
1315
+ if (!isJsonString(state) || !isSetMember(VALID_CLOSURE_STATES, state) || state === 'open') {
1316
+ throw new HttpError(400, 'state is required and must be one of: closed | closed-unknown');
890
1317
  }
891
- // GET /v1/recall/drill/:id?limit=N&budget=N
892
- // Companion to /v1/memories. When recall surfaces a level-2 summary in
893
- // place of overflowed children (RecallResultItem.isSummary === true), the
894
- // caller drills into the summary id to recover the originals. Tenant
895
- // scoped via Bearer; default-deny on private scopes for both summary
896
- // and children.
897
- const drillMatch = matchPath('/v1/recall/drill/:id', path);
898
- if (method === 'GET' && drillMatch) {
899
- validateIdSegment(drillMatch.id, 'summary id');
900
- const limitRaw = query.get('limit');
901
- const limit = limitRaw === null ? undefined : Number(limitRaw);
902
- if (limit !== undefined && (!Number.isFinite(limit) || limit <= 0)) {
903
- throw new HttpError(400, 'limit must be a positive number');
1318
+ const actual = body['actual'];
1319
+ let actualValue;
1320
+ if (actual !== undefined && actual !== null) {
1321
+ if (!isJsonNumber(actual) || !Number.isFinite(actual)) {
1322
+ throw new HttpError(400, 'actual must be a finite number');
904
1323
  }
905
- const budgetRaw = query.get('budget');
906
- const budget = budgetRaw === null ? undefined : Number(budgetRaw);
907
- if (budget !== undefined && (!Number.isFinite(budget) || budget <= 0)) {
908
- throw new HttpError(400, 'budget must be a positive number');
909
- }
910
- // v0.30 / E5: depth query param walks N levels (default 1, hard cap 10).
911
- const depthRaw = query.get('depth');
912
- let depth;
913
- if (depthRaw !== null) {
914
- const parsed = Number(depthRaw);
915
- // L4 fold: reject out-of-range explicitly (no silent clamp).
916
- if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10) {
917
- throw new HttpError(400, 'depth must be a positive integer between 1 and 10');
918
- }
919
- depth = parsed;
1324
+ actualValue = actual;
1325
+ }
1326
+ const note = body['note'];
1327
+ let closureNote;
1328
+ if (note !== undefined && note !== null) {
1329
+ if (!isJsonString(note)) {
1330
+ throw new HttpError(400, 'note must be a string');
920
1331
  }
921
- const ctx = await buildContextWithAuth(req, opts);
922
- const drillExtra = {};
923
- if (limit !== undefined)
924
- drillExtra.limit = limit;
925
- if (budget !== undefined)
926
- drillExtra.budget = budget;
927
- if (depth !== undefined)
928
- drillExtra.depth = depth;
929
- const result = drillDown(ctx, drillMatch.id, { ...drillExtra, cost: drillCost });
930
- if ('failure' in result) {
931
- // v1.6.4: leaf id maps to 422 (caller-actionable). Other cases stay
932
- // as 404 to avoid leaking cross-tenant existence or scope grants.
933
- if (result.failure === 'not_drillable') {
934
- throw new HttpError(422, 'Id is a leaf row, not a level-2+ summary; nothing to drill into');
935
- }
936
- throw new HttpError(404, 'No drillable summary at this id');
1332
+ if (note.length > 2048) {
1333
+ throw new HttpError(400, 'note exceeds 2048-character cap');
937
1334
  }
938
- sendJson(res, 200, result);
939
- return;
1335
+ closureNote = note;
940
1336
  }
941
- // /v1/memories/:id/* and DELETE /v1/memories/:id
942
- const archiveMatch = matchPath('/v1/memories/:id/archive', path);
943
- if (method === 'POST' && archiveMatch) {
944
- validateIdSegment(archiveMatch.id, 'memory id');
945
- const body = await parseJsonBody(req);
946
- const reason = getString(body, 'reason');
947
- if (!reason) {
948
- throw new HttpError(400, 'reason is required');
949
- }
950
- const ctx = await buildContextWithAuth(req, opts);
951
- const result = archiveRaw(ctx, archiveMatch.id, reason);
952
- sendJson(res, 200, result);
953
- return;
1337
+ const ctx = await buildContextWithAuth(req, opts);
1338
+ try {
1339
+ const prediction = closePrediction(opts.hippoRoot, ctx.tenantId, id, {
1340
+ closureState: state,
1341
+ actualValue,
1342
+ closureNote,
1343
+ }, ctx.actor.subject);
1344
+ sendJson(res, 200, { prediction });
954
1345
  }
955
- const supersedeMatch = matchPath('/v1/memories/:id/supersede', path);
956
- if (method === 'POST' && supersedeMatch) {
957
- validateIdSegment(supersedeMatch.id, 'memory id');
958
- const body = await parseJsonBody(req);
959
- const content = getString(body, 'content');
960
- if (!content) {
961
- throw new HttpError(400, 'content is required');
1346
+ catch (e) {
1347
+ const msg = e instanceof Error ? e.message : String(e);
1348
+ if (msg.includes('not found')) {
1349
+ throw new HttpError(404, msg);
962
1350
  }
963
- const ctx = await buildContextWithAuth(req, opts);
964
- const result = supersede(ctx, supersedeMatch.id, content);
965
- sendJson(res, 200, result);
966
- return;
967
- }
968
- const promoteMatch = matchPath('/v1/memories/:id/promote', path);
969
- if (method === 'POST' && promoteMatch) {
970
- validateIdSegment(promoteMatch.id, 'memory id');
971
- const ctx = await buildContextWithAuth(req, opts);
972
- const result = promote(ctx, promoteMatch.id);
973
- sendJson(res, 200, result);
974
- return;
1351
+ throw e;
975
1352
  }
976
- const idMatch = matchPath('/v1/memories/:id', path);
977
- if (method === 'DELETE' && idMatch) {
978
- validateIdSegment(idMatch.id, 'memory id');
979
- const ctx = await buildContextWithAuth(req, opts);
980
- const result = forget(ctx, idMatch.id);
981
- sendJson(res, 200, result);
982
- return;
1353
+ return;
1354
+ }
1355
+ // ── decisions (E2 first-class object) ──
1356
+ //
1357
+ // 5 routes: POST /v1/decisions (create, optional supersedesDecisionId),
1358
+ // GET /v1/decisions (list, status filter), GET /v1/decisions/:id (show),
1359
+ // POST /v1/decisions/:id/supersede (create a successor + supersede :id),
1360
+ // POST /v1/decisions/:id/close (retire). Bearer-authed + tenant-scoped via
1361
+ // buildContextWithAuth. status validated against VALID_DECISION_STATES.
1362
+ // DoS caps: text 4096, context 4096 (v1.11.4 pattern). The HTTP surface is
1363
+ // new (no legacy --supersedes <memory-id> constraint), so it supersedes by
1364
+ // table id and never weakens a memory mirror.
1365
+ async function handleCreateDecision({ req, res, opts }) {
1366
+ const body = await parseJsonBody(req);
1367
+ const text = body['text'];
1368
+ if (!isJsonString(text) || text.length === 0) {
1369
+ throw new HttpError(400, 'text is required (non-empty string)');
1370
+ }
1371
+ if (text.length > 4096) {
1372
+ throw new HttpError(400, 'text exceeds 4096-character cap');
1373
+ }
1374
+ const contextRaw = body['context'];
1375
+ let context;
1376
+ if (contextRaw !== undefined && contextRaw !== null) {
1377
+ if (!isJsonString(contextRaw)) {
1378
+ throw new HttpError(400, 'context must be a string');
1379
+ }
1380
+ if (contextRaw.length > 4096) {
1381
+ throw new HttpError(400, 'context exceeds 4096-character cap');
1382
+ }
1383
+ context = contextRaw;
1384
+ }
1385
+ const supRaw = body['supersedesDecisionId'];
1386
+ let supersedesDecisionId;
1387
+ if (supRaw !== undefined && supRaw !== null) {
1388
+ if (!isJsonNumber(supRaw) || !Number.isInteger(supRaw) || supRaw <= 0) {
1389
+ throw new HttpError(400, 'supersedesDecisionId must be a positive integer');
1390
+ }
1391
+ supersedesDecisionId = supRaw;
1392
+ }
1393
+ const ctx = await buildContextWithAuth(req, opts);
1394
+ try {
1395
+ const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
1396
+ decisionText: text,
1397
+ context,
1398
+ supersedesDecisionId,
1399
+ }, ctx.actor.subject);
1400
+ sendJson(res, 201, { decision });
983
1401
  }
984
- // POST /v1/outcome — apply a positive/negative outcome to memory ids.
985
- // Body: {ids?: string[], good: boolean}. If ids omitted, falls back to
986
- // the last-recall path (api.outcomeForLastRecall); returned shape is
987
- // {applied, ids} in that case so callers can disambiguate "no recent
988
- // recall" from "all ids skipped". Each applied id writes one audit_log
989
- // row (op='outcome', actor from Bearer).
990
- if (method === 'POST' && path === '/v1/outcome') {
991
- const body = await parseJsonBody(req);
992
- const good = body['good'];
993
- if (!isJsonBoolean(good)) {
994
- throw new HttpError(400, 'good is required (boolean)');
995
- }
996
- const idsRaw = body['ids'];
997
- let ids;
998
- if (idsRaw !== undefined) {
999
- if (!Array.isArray(idsRaw)) {
1000
- throw new HttpError(400, 'ids must be an array of non-empty strings');
1001
- }
1002
- const isNonEmptyId = (item) => isJsonString(item) && item.length > 0;
1003
- if (!idsRaw.every(isNonEmptyId)) {
1004
- throw new HttpError(400, 'ids must be an array of non-empty strings');
1005
- }
1006
- // v1.11.5: DoS cap on ids.length. Each id triggers ~3 DB ops (readEntry +
1007
- // writeEntry + appendAuditEvent). N=1000 keeps per-request work bounded
1008
- // to sub-second wall time on SQLite hot path. Cap BEFORE buildContextWithAuth
1009
- // so attack traffic doesn't pay the api-key lookup cost.
1010
- if (idsRaw.length > 1000) {
1011
- throw new HttpError(400, 'ids exceeds 1000-id cap');
1012
- }
1013
- ids = idsRaw;
1014
- }
1015
- const ctx = await buildContextWithAuth(req, opts);
1016
- if (ids !== undefined) {
1017
- const { applied } = outcome(ctx, ids, good);
1018
- sendJson(res, 200, { applied });
1019
- }
1020
- else {
1021
- const result = outcomeForLastRecall(ctx, good);
1022
- sendJson(res, 200, result);
1402
+ catch (e) {
1403
+ const msg = e instanceof Error ? e.message : String(e);
1404
+ if (msg.includes('not found') || msg.includes('not active')) {
1405
+ throw new HttpError(409, msg);
1023
1406
  }
1024
- return;
1407
+ throw e;
1025
1408
  }
1026
- // GET /v1/context — assemble a budget-bounded context bundle. Returns
1027
- // ContextResult JSON (entries + tokens + activeSnapshot + sessionHandoff
1028
- // + recentEvents). No server-side rendering; clients render. Tenant-scoped
1029
- // via the Bearer. Pinned-only + '*' fallback skip the recall audit emit
1030
- // (matches cmdContext); real-query hybrid search emits one 'recall' row.
1031
- if (method === 'GET' && path === '/v1/context') {
1032
- const q = query.get('q') ?? undefined;
1033
- // v1.11.5: DoS cap on q-param length. 1024 covers real multi-clause queries
1034
- // (pasted error messages, multi-stem searches) while bounding BM25
1035
- // tokenisation cost (~150 tokens worst case at 1024 chars).
1036
- if (q !== undefined && q.length > 1024) {
1037
- throw new HttpError(400, 'q exceeds 1024-character cap');
1038
- }
1039
- const budgetRaw = query.get('budget');
1040
- let budget;
1041
- if (budgetRaw !== null) {
1042
- budget = Number(budgetRaw);
1043
- if (!Number.isFinite(budget) || budget < 0) {
1044
- throw new HttpError(400, 'budget must be a non-negative number');
1045
- }
1046
- }
1047
- const limitRaw = query.get('limit');
1048
- let limit;
1049
- if (limitRaw !== null) {
1050
- limit = Number(limitRaw);
1051
- if (!Number.isFinite(limit) || limit <= 0) {
1052
- throw new HttpError(400, 'limit must be a positive number');
1053
- }
1054
- }
1055
- const pinnedOnlyRaw = query.get('pinned_only');
1056
- const pinnedOnly = pinnedOnlyRaw === '1' || pinnedOnlyRaw === 'true';
1057
- const scopeRaw = query.get('scope');
1058
- if (scopeRaw !== null && scopeRaw.length > 256) {
1059
- throw new HttpError(400, 'scope exceeds 256-character cap');
1060
- }
1061
- const scope = scopeRaw === null ? undefined : scopeRaw;
1062
- const includeRecentRaw = query.get('include_recent');
1063
- let includeRecent;
1064
- if (includeRecentRaw !== null) {
1065
- includeRecent = Number(includeRecentRaw);
1066
- if (!Number.isFinite(includeRecent) || includeRecent < 0) {
1067
- throw new HttpError(400, 'include_recent must be a non-negative number');
1068
- }
1069
- }
1070
- // v39 memory scope isolation: cross_project=1|true re-includes
1071
- // other-project rows (tagged category 'cross-project' in the response).
1072
- // The partition identity comes from the SERVED STORE's location, not the
1073
- // daemon's process cwd - a daemon started from anywhere still isolates
1074
- // the project it serves.
1075
- const crossProjectRaw = query.get('cross_project');
1076
- const crossProject = crossProjectRaw === '1' || crossProjectRaw === 'true';
1077
- const ctx = await buildContextWithAuth(req, opts);
1078
- const result = await getContext(ctx, {
1079
- q,
1080
- budget,
1409
+ return;
1410
+ }
1411
+ async function handleListDecisions({ req, res, opts, query }) {
1412
+ const status = query.get('status') ?? 'all';
1413
+ const limit = parseListLimit(query.get('limit'));
1414
+ const ctx = await buildContextWithAuth(req, opts);
1415
+ let decisions;
1416
+ if (status === 'all') {
1417
+ decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, { limit });
1418
+ }
1419
+ else {
1420
+ if (!isSetMember(VALID_DECISION_STATES, status)) {
1421
+ throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1422
+ }
1423
+ decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, {
1424
+ status,
1081
1425
  limit,
1082
- pinnedOnly,
1083
- scope,
1084
- includeRecent,
1085
- crossProject,
1086
- currentProject: resolveProjectIdentity(dirname(resolve(opts.hippoRoot))).name,
1087
- cost: contextCost('markdown', 'observe'), // clients render; the budget prices the block `hippo context` would print
1088
1426
  });
1089
- recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
1090
- sendJson(res, 200, result);
1091
- return;
1092
1427
  }
1093
- // POST /v1/sleep — host-wide consolidation pipeline (consolidate + dedup +
1094
- // audit + share + ambient). serve() refuses non-loopback hosts at boot, AND
1095
- // this per-request loopback assertion makes the host-wide semantic fail-
1096
- // closed regardless of any future serve() boot-config change. Body:
1097
- // {dry_run?, no_share?}. Returns SleepResult JSON.
1098
- //
1099
- // Tenant scope (Episode A follow-up tracked in TODOS.md): api.sleep operates
1100
- // on the WHOLE hippoRoot (cross-tenant by design, matching CLI cmdSleep).
1101
- // The loopback-only guard is the trust boundary today. Future non-loopback
1102
- // serving must also zero the cross-tenant counters for other tenants
1103
- // (D1 in docs/decisions/2026-05-24-blocked-items.md).
1104
- if (method === 'POST' && path === '/v1/sleep') {
1105
- // Defensive per-request loopback guard. Uses the canonical isLoopback()
1106
- // helper above so any future extension (additional mapped/IPv6 forms,
1107
- // NAT64 prefixes) flows through without drift. serve()'s boot-time host
1108
- // check is the primary trust boundary; this is belt-and-suspenders.
1109
- if (!isLoopback(req.socket.remoteAddress)) {
1110
- throw new HttpError(403, '/v1/sleep is loopback-only (host-wide consolidation; see CHANGELOG v1.11.4)');
1111
- }
1112
- // v1.12.0 A5 v2 sub-1: admin-role gate. Forward-defensive — exists today
1113
- // under loopback-only enforcement (loopback fallback is admin by default;
1114
- // any Bearer-authed caller now carries an explicit role from the api_keys
1115
- // row). When non-loopback serving lands, this gate is the actual auth
1116
- // boundary on host-wide sleep.
1117
- const sleepCtx = await buildContextWithAuth(req, opts);
1118
- // Sleep consolidates every tenant under hippoRoot, so it is a cross-tenant action.
1119
- assertCrossTenantAdmin(sleepCtx, '/v1/sleep');
1120
- const body = await parseJsonBody(req);
1121
- const dryRunRaw = body['dry_run'];
1122
- if (dryRunRaw !== undefined && !isJsonBoolean(dryRunRaw)) {
1123
- throw new HttpError(400, 'dry_run must be a boolean');
1124
- }
1125
- const noShareRaw = body['no_share'];
1126
- if (noShareRaw !== undefined && !isJsonBoolean(noShareRaw)) {
1127
- throw new HttpError(400, 'no_share must be a boolean');
1128
- }
1129
- // v1.12.0: sleepCtx already built above for the admin-role gate; reuse.
1130
- const result = await sleep(sleepCtx, {
1131
- dryRun: dryRunRaw === true,
1132
- noShare: noShareRaw === true,
1133
- });
1134
- sendJson(res, 200, result);
1135
- return;
1428
+ sendJson(res, 200, { decisions });
1429
+ return;
1430
+ }
1431
+ async function handleSupersedeDecision({ req, res, opts }, decisionSupersedeMatch) {
1432
+ const oldId = parseInt(decisionSupersedeMatch[1], 10);
1433
+ const body = await parseJsonBody(req);
1434
+ const text = body['text'];
1435
+ if (!isJsonString(text) || text.length === 0) {
1436
+ throw new HttpError(400, 'text is required (non-empty string)');
1136
1437
  }
1137
- // POST /v1/auth/keys — mint a new API key. Plaintext lands in the response
1138
- // body (Task 8): the HTTP layer hands it to the client; the user-facing
1139
- // "store this somewhere safe" warning belongs in the CLI client, not here.
1140
- if (method === 'POST' && path === '/v1/auth/keys') {
1141
- const body = await parseJsonBody(req);
1142
- const labelRaw = body['label'];
1143
- if (labelRaw !== undefined && !isJsonString(labelRaw)) {
1144
- throw new HttpError(400, 'label must be a string');
1145
- }
1146
- // v1.12.3: optional body.role mirrors the --role CLI flag. Validated
1147
- // strictly — anything other than 'admin'|'member' is a 400 (no silent
1148
- // fallback to admin). authCreate refuses a member caller with a 403.
1149
- const roleRaw = body['role'];
1150
- let role;
1151
- if (roleRaw !== undefined) {
1152
- if (roleRaw !== 'admin' && roleRaw !== 'member') {
1153
- throw new HttpError(400, "role must be 'admin' or 'member'");
1154
- }
1155
- role = roleRaw;
1156
- }
1157
- // Security: any `tenantId` in the body is IGNORED. The minted key is
1158
- // bound to the caller's authenticated tenant (ctx.tenantId, resolved
1159
- // from the Bearer token). Forwarding body.tenantId here would let
1160
- // tenant A mint a key for tenant B — see authCreate doc comment.
1161
- const ctx = await buildContextWithAuth(req, opts);
1162
- const result = authCreate(ctx, {
1163
- label: labelRaw,
1164
- role,
1165
- });
1166
- sendJson(res, 200, result);
1167
- return;
1438
+ if (text.length > 4096) {
1439
+ throw new HttpError(400, 'text exceeds 4096-character cap');
1168
1440
  }
1169
- // GET /v1/auth/keys?active=true — list keys visible to ctx.tenantId.
1170
- // `active` defaults to true so the common case (show me usable keys) is
1171
- // a single GET; ?active=false includes revoked rows.
1172
- if (method === 'GET' && path === '/v1/auth/keys') {
1173
- const activeRaw = query.get('active');
1174
- let active = true;
1175
- if (activeRaw !== null) {
1176
- if (activeRaw === 'true')
1177
- active = true;
1178
- else if (activeRaw === 'false')
1179
- active = false;
1180
- else
1181
- throw new HttpError(400, "active must be 'true' or 'false'");
1441
+ const contextRaw = body['context'];
1442
+ let context;
1443
+ if (contextRaw !== undefined && contextRaw !== null) {
1444
+ if (!isJsonString(contextRaw)) {
1445
+ throw new HttpError(400, 'context must be a string');
1182
1446
  }
1183
- const ctx = await buildContextWithAuth(req, opts);
1184
- const result = authList(ctx, { active });
1185
- sendJson(res, 200, result);
1186
- return;
1187
- }
1188
- // DELETE /v1/auth/keys/:keyId — revoke. Missing or cross-tenant keys are 404
1189
- // (no info leak); a member key targeting any key but its own is 403.
1190
- // 200 with the body rather than 204 so the caller sees revokedAt.
1191
- const keyMatch = matchPath('/v1/auth/keys/:keyId', path);
1192
- if (method === 'DELETE' && keyMatch) {
1193
- validateIdSegment(keyMatch.keyId, 'key id');
1194
- const ctx = await buildContextWithAuth(req, opts);
1195
- const result = authRevoke(ctx, keyMatch.keyId);
1196
- sendJson(res, 200, result);
1197
- return;
1198
- }
1199
- // GET /v1/quarantine?status=: CD5 review queue. quarantineList carries no role gate itself, so it's checked here.
1200
- if (method === 'GET' && path === '/v1/quarantine') {
1201
- const ctx = await buildContextWithAuth(req, opts);
1202
- if (ctx.actor.role !== 'admin') {
1203
- throw new HttpError(403, '/v1/quarantine requires admin role');
1204
- }
1205
- const statusRaw = query.get('status');
1206
- let status = 'pending';
1207
- if (statusRaw !== null) {
1208
- if (statusRaw !== 'pending' && statusRaw !== 'approved' && statusRaw !== 'rejected' && statusRaw !== 'all') {
1209
- throw new HttpError(400, 'status must be one of: pending | approved | rejected | all');
1210
- }
1211
- status = statusRaw;
1447
+ if (contextRaw.length > 4096) {
1448
+ throw new HttpError(400, 'context exceeds 4096-character cap');
1212
1449
  }
1213
- sendJson(res, 200, { quarantine: quarantineList(ctx, { status }) });
1214
- return;
1450
+ context = contextRaw;
1215
1451
  }
1216
- // POST /v1/quarantine/:id/approve: admin only; ForbiddenError falls through to mapApiError's 403.
1217
- const quarantineApproveMatch = matchPath('/v1/quarantine/:id/approve', path);
1218
- if (method === 'POST' && quarantineApproveMatch) {
1219
- validateIdSegment(quarantineApproveMatch.id, 'memory id');
1220
- const ctx = await buildContextWithAuth(req, opts);
1221
- try {
1222
- quarantineApprove(ctx, quarantineApproveMatch.id);
1223
- sendJson(res, 200, { approved: quarantineApproveMatch.id });
1224
- }
1225
- catch (e) {
1226
- const msg = e instanceof Error ? e.message : String(e);
1227
- if (msg.includes('not quarantined'))
1228
- throw new HttpError(404, msg);
1229
- if (msg.includes('is already') || msg.includes('scope changed'))
1230
- throw new HttpError(409, msg);
1231
- throw e;
1232
- }
1233
- return;
1452
+ const ctx = await buildContextWithAuth(req, opts);
1453
+ try {
1454
+ const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
1455
+ decisionText: text,
1456
+ context,
1457
+ supersedesDecisionId: oldId,
1458
+ }, ctx.actor.subject);
1459
+ sendJson(res, 201, { decision });
1234
1460
  }
1235
- // POST /v1/quarantine/:id/reject: admin only; ForbiddenError falls through to mapApiError's 403.
1236
- const quarantineRejectMatch = matchPath('/v1/quarantine/:id/reject', path);
1237
- if (method === 'POST' && quarantineRejectMatch) {
1238
- validateIdSegment(quarantineRejectMatch.id, 'memory id');
1239
- const ctx = await buildContextWithAuth(req, opts);
1240
- try {
1241
- quarantineReject(ctx, quarantineRejectMatch.id);
1242
- sendJson(res, 200, { rejected: quarantineRejectMatch.id });
1243
- }
1244
- catch (e) {
1245
- const msg = e instanceof Error ? e.message : String(e);
1246
- if (msg.includes('not quarantined'))
1247
- throw new HttpError(404, msg);
1248
- if (msg.includes('is already'))
1249
- throw new HttpError(409, msg);
1250
- throw e;
1461
+ catch (e) {
1462
+ const msg = e instanceof Error ? e.message : String(e);
1463
+ if (msg.includes('not found')) {
1464
+ throw new HttpError(404, msg);
1251
1465
  }
1252
- return;
1253
- }
1254
- // GET /v1/audit?op=&since=&limit= — read audit events. All three filters
1255
- // validated at the route boundary so an invalid value lands a 400 before
1256
- // we hit the DB.
1257
- if (method === 'GET' && path === '/v1/audit') {
1258
- const opRaw = query.get('op');
1259
- let op;
1260
- if (opRaw !== null) {
1261
- if (!isSetMember(VALID_AUDIT_OPS, opRaw)) {
1262
- throw new HttpError(400, `invalid op: ${opRaw}`);
1263
- }
1264
- op = opRaw;
1265
- }
1266
- const sinceRaw = query.get('since');
1267
- let since;
1268
- if (sinceRaw !== null) {
1269
- const parsed = Date.parse(sinceRaw);
1270
- if (!Number.isFinite(parsed)) {
1271
- throw new HttpError(400, `invalid since: ${sinceRaw}`);
1272
- }
1273
- since = sinceRaw;
1274
- }
1275
- const limitRaw = query.get('limit');
1276
- let limit;
1277
- if (limitRaw !== null) {
1278
- const parsed = Number(limitRaw);
1279
- if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > MAX_AUDIT_LIMIT) {
1280
- throw new HttpError(400, `limit must be an integer between 1 and ${MAX_AUDIT_LIMIT}`);
1281
- }
1282
- limit = parsed;
1466
+ if (msg.includes('not active')) {
1467
+ throw new HttpError(409, msg);
1283
1468
  }
1284
- const ctx = await buildContextWithAuth(req, opts);
1285
- // ?tenant=<t> reads another tenant (e.g. '__host__' for consolidate rows); admin only.
1286
- const tenantOverride = query.get('tenant');
1287
- const crossTenant = tenantOverride !== null && tenantOverride !== '' && tenantOverride !== ctx.tenantId;
1288
- if (crossTenant)
1289
- assertCrossTenantAdmin(ctx, '/v1/audit?tenant= for another tenant');
1290
- const effectiveCtx = crossTenant ? { ...ctx, tenantId: tenantOverride } : ctx;
1291
- const result = auditList(effectiveCtx, { op, since, limit });
1292
- sendJson(res, 200, result);
1293
- return;
1469
+ throw e;
1294
1470
  }
1295
- // ── E2 prediction first-class object (v0.31) ──
1296
- // docs/plans/2026-05-26-e2-prediction-object.md
1297
- //
1298
- // 4 routes: POST /v1/predictions (create), GET /v1/predictions (list),
1299
- // GET /v1/predictions/:id (show), POST /v1/predictions/:id/close (close).
1300
- // All Bearer-authed + tenant-scoped via buildContextWithAuth. closure_state
1301
- // validated against VALID_CLOSURE_STATES (3 states). DoS caps on claim
1302
- // (4096 chars) + closureNote (2048 chars) per v1.11.4 pattern.
1303
- if (method === 'POST' && path === '/v1/predictions') {
1304
- const body = await parseJsonBody(req);
1305
- const claim = body['claim'];
1306
- if (!isJsonString(claim) || claim.length === 0) {
1307
- throw new HttpError(400, 'claim is required (non-empty string)');
1308
- }
1309
- if (claim.length > 4096) {
1310
- throw new HttpError(400, 'claim exceeds 4096-character cap');
1311
- }
1312
- const classTag = body['classTag'];
1313
- if (!isJsonString(classTag) || classTag.length === 0) {
1314
- throw new HttpError(400, 'classTag is required (non-empty string)');
1315
- }
1316
- const estimate = body['estimate'];
1317
- let estimateValue;
1318
- if (estimate !== undefined && estimate !== null) {
1319
- if (!isJsonNumber(estimate) || !Number.isFinite(estimate)) {
1320
- throw new HttpError(400, 'estimate must be a finite number');
1321
- }
1322
- estimateValue = estimate;
1323
- }
1324
- const unit = body['unit'];
1325
- let estimateUnit;
1326
- if (unit !== undefined && unit !== null) {
1327
- if (!isJsonString(unit)) {
1328
- throw new HttpError(400, 'unit must be a string');
1329
- }
1330
- estimateUnit = unit;
1471
+ return;
1472
+ }
1473
+ async function handleCloseDecision({ req, res, opts }, decisionCloseMatch) {
1474
+ const id = parseInt(decisionCloseMatch[1], 10);
1475
+ const ctx = await buildContextWithAuth(req, opts);
1476
+ try {
1477
+ const decision = closeDecision(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1478
+ sendJson(res, 200, { decision });
1479
+ }
1480
+ catch (e) {
1481
+ const msg = e instanceof Error ? e.message : String(e);
1482
+ if (msg.includes('not found')) {
1483
+ throw new HttpError(404, msg);
1331
1484
  }
1332
- const targetDate = body['targetDate'];
1333
- let targetDateValue;
1334
- if (targetDate !== undefined && targetDate !== null) {
1335
- if (!isJsonString(targetDate)) {
1336
- throw new HttpError(400, 'targetDate must be an ISO date string');
1337
- }
1338
- targetDateValue = targetDate;
1485
+ if (msg.includes('not active')) {
1486
+ throw new HttpError(409, msg);
1339
1487
  }
1340
- const ctx = await buildContextWithAuth(req, opts);
1341
- const prediction = savePrediction(opts.hippoRoot, ctx.tenantId, {
1342
- classTag,
1343
- claimText: claim,
1344
- estimateValue,
1345
- estimateUnit,
1346
- targetDate: targetDateValue,
1488
+ throw e;
1489
+ }
1490
+ return;
1491
+ }
1492
+ async function handleGetDecision({ req, res, opts }, decisionByIdMatch) {
1493
+ const id = parseInt(decisionByIdMatch[1], 10);
1494
+ const ctx = await buildContextWithAuth(req, opts);
1495
+ const decision = loadDecisionById(opts.hippoRoot, ctx.tenantId, id);
1496
+ if (!decision) {
1497
+ throw new HttpError(404, `decision ${id} not found`);
1498
+ }
1499
+ sendJson(res, 200, { decision });
1500
+ return;
1501
+ }
1502
+ // ── incidents (E2 first-class object) ──
1503
+ //
1504
+ // 5 routes: POST /v1/incidents (open; body text + context + linkedMemoryIds[]),
1505
+ // GET /v1/incidents (list, status filter), GET /v1/incidents/:id (show),
1506
+ // POST /v1/incidents/:id/resolve (open -> resolved; body resolutionText),
1507
+ // POST /v1/incidents/:id/close (open|resolved -> closed). Bearer-authed +
1508
+ // tenant-scoped via buildContextWithAuth. status validated against
1509
+ // VALID_INCIDENT_STATES. DoS caps: text 4096, context 4096, resolutionText
1510
+ // 4096 (v1.11.4 pattern). Mirrors /v1/decisions; lifecycle is
1511
+ // open->resolved->closed (no supersede), so linkedMemoryIds replaces
1512
+ // supersedesDecisionId on create.
1513
+ async function handleCreateIncident({ req, res, opts }) {
1514
+ const body = await parseJsonBody(req);
1515
+ const text = body['text'];
1516
+ if (!isJsonString(text) || text.length === 0) {
1517
+ throw new HttpError(400, 'text is required (non-empty string)');
1518
+ }
1519
+ if (text.length > 4096) {
1520
+ throw new HttpError(400, 'text exceeds 4096-character cap');
1521
+ }
1522
+ const contextRaw = body['context'];
1523
+ let context;
1524
+ if (contextRaw !== undefined && contextRaw !== null) {
1525
+ if (!isJsonString(contextRaw)) {
1526
+ throw new HttpError(400, 'context must be a string');
1527
+ }
1528
+ if (contextRaw.length > 4096) {
1529
+ throw new HttpError(400, 'context exceeds 4096-character cap');
1530
+ }
1531
+ context = contextRaw;
1532
+ }
1533
+ const linkedRaw = body['linkedMemoryIds'];
1534
+ let linkedMemoryIds;
1535
+ if (linkedRaw !== undefined && linkedRaw !== null) {
1536
+ if (!Array.isArray(linkedRaw)) {
1537
+ throw new HttpError(400, 'linkedMemoryIds must be an array of memory ids');
1538
+ }
1539
+ if (linkedRaw.length > 256) {
1540
+ throw new HttpError(400, 'linkedMemoryIds exceeds 256-item cap');
1541
+ }
1542
+ const isValidMemoryId = (item) => isJsonString(item) && item.length > 0 && item.length <= 4096;
1543
+ if (!linkedRaw.every(isValidMemoryId)) {
1544
+ throw new HttpError(400, 'each linkedMemoryIds entry must be a non-empty string <= 4096 chars');
1545
+ }
1546
+ linkedMemoryIds = linkedRaw;
1547
+ }
1548
+ const ctx = await buildContextWithAuth(req, opts);
1549
+ try {
1550
+ const incident = saveIncident(opts.hippoRoot, ctx.tenantId, {
1551
+ incidentText: text,
1552
+ context,
1553
+ linkedMemoryIds,
1347
1554
  }, ctx.actor.subject);
1348
- sendJson(res, 201, { prediction });
1349
- return;
1555
+ sendJson(res, 201, { incident });
1350
1556
  }
1351
- if (method === 'GET' && path === '/v1/predictions') {
1352
- const classTag = query.get('class') ?? undefined;
1353
- const status = query.get('status') ?? 'all';
1354
- const limit = parseListLimit(query.get('limit'));
1355
- const ctx = await buildContextWithAuth(req, opts);
1356
- let predictions;
1357
- if (status === 'all') {
1358
- if (classTag) {
1359
- predictions = loadPredictionsByClass(opts.hippoRoot, ctx.tenantId, classTag, { limit });
1360
- }
1361
- else {
1362
- predictions = loadOpenPredictions(opts.hippoRoot, ctx.tenantId, { limit });
1363
- }
1364
- }
1365
- else if (status === 'open') {
1366
- predictions = loadOpenPredictions(opts.hippoRoot, ctx.tenantId, {
1367
- classTag: classTag || undefined,
1368
- limit,
1369
- });
1370
- }
1371
- else {
1372
- if (!isSetMember(VALID_CLOSURE_STATES, status)) {
1373
- throw new HttpError(400, `status must be one of: open | closed | closed-unknown | all (got "${status}")`);
1374
- }
1375
- if (!classTag) {
1376
- throw new HttpError(400, 'status filter (non-open) requires class param');
1377
- }
1378
- predictions = loadPredictionsByClass(opts.hippoRoot, ctx.tenantId, classTag, {
1379
- closureState: status,
1380
- limit,
1381
- });
1557
+ catch (e) {
1558
+ const msg = e instanceof Error ? e.message : String(e);
1559
+ if (msg.includes('not found')) {
1560
+ throw new HttpError(409, msg);
1382
1561
  }
1383
- sendJson(res, 200, { predictions });
1384
- return;
1562
+ throw e;
1563
+ }
1564
+ return;
1565
+ }
1566
+ async function handleListIncidents({ req, res, opts, query }) {
1567
+ const status = query.get('status') ?? 'all';
1568
+ const limit = parseListLimit(query.get('limit'));
1569
+ const ctx = await buildContextWithAuth(req, opts);
1570
+ let incidents;
1571
+ if (status === 'all') {
1572
+ incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, { limit });
1573
+ }
1574
+ else {
1575
+ if (!isSetMember(VALID_INCIDENT_STATES, status)) {
1576
+ throw new HttpError(400, `status must be one of: open | resolved | closed | all (got "${status}")`);
1577
+ }
1578
+ incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, {
1579
+ status,
1580
+ limit,
1581
+ });
1582
+ }
1583
+ sendJson(res, 200, { incidents });
1584
+ return;
1585
+ }
1586
+ async function handleResolveIncident({ req, res, opts }, incidentResolveMatch) {
1587
+ const id = parseInt(incidentResolveMatch[1], 10);
1588
+ const body = await parseJsonBody(req);
1589
+ const resolutionText = body['resolutionText'];
1590
+ if (!isJsonString(resolutionText) || resolutionText.trim().length === 0) {
1591
+ throw new HttpError(400, 'resolutionText is required (non-empty string)');
1592
+ }
1593
+ if (resolutionText.length > 4096) {
1594
+ throw new HttpError(400, 'resolutionText exceeds 4096-character cap');
1385
1595
  }
1386
- // J3 reference-class / planning-fallacy detector (v0.31).
1387
- // Order matters: this must match BEFORE /v1/predictions/:id since 'stats'
1388
- // is not a number — the :id regex requires \d+ so they don't conflict,
1389
- // but routing this first avoids the dispatch order risk.
1390
- if (method === 'GET' && path === '/v1/predictions/stats') {
1391
- const classTag = query.get('class');
1392
- if (!classTag || classTag.length === 0) {
1393
- throw new HttpError(400, 'class param is required');
1596
+ const ctx = await buildContextWithAuth(req, opts);
1597
+ try {
1598
+ const incident = resolveIncident(opts.hippoRoot, ctx.tenantId, id, resolutionText, ctx.actor.subject);
1599
+ sendJson(res, 200, { incident });
1600
+ }
1601
+ catch (e) {
1602
+ const msg = e instanceof Error ? e.message : String(e);
1603
+ if (msg.includes('not found')) {
1604
+ throw new HttpError(404, msg);
1394
1605
  }
1395
- if (classTag.length > 256) {
1396
- throw new HttpError(400, 'class exceeds 256-character cap');
1606
+ if (msg.includes('not open')) {
1607
+ throw new HttpError(409, msg);
1397
1608
  }
1398
- const ctx = await buildContextWithAuth(req, opts);
1399
- const baserate = computePredictionBaserate(opts.hippoRoot, ctx.tenantId, classTag, ctx.actor.subject);
1400
- sendJson(res, 200, { baserate });
1401
- return;
1609
+ throw e;
1402
1610
  }
1403
- const predictionByIdMatch = path.match(/^\/v1\/predictions\/(\d+)$/);
1404
- if (method === 'GET' && predictionByIdMatch) {
1405
- const id = parseInt(predictionByIdMatch[1], 10);
1406
- const ctx = await buildContextWithAuth(req, opts);
1407
- const prediction = loadPredictionById(opts.hippoRoot, ctx.tenantId, id);
1408
- if (!prediction) {
1409
- throw new HttpError(404, `prediction ${id} not found`);
1410
- }
1411
- sendJson(res, 200, { prediction });
1412
- return;
1611
+ return;
1612
+ }
1613
+ async function handleCloseIncident({ req, res, opts }, incidentCloseMatch) {
1614
+ const id = parseInt(incidentCloseMatch[1], 10);
1615
+ const ctx = await buildContextWithAuth(req, opts);
1616
+ try {
1617
+ const incident = closeIncident(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1618
+ sendJson(res, 200, { incident });
1413
1619
  }
1414
- const predictionCloseMatch = path.match(/^\/v1\/predictions\/(\d+)\/close$/);
1415
- if (method === 'POST' && predictionCloseMatch) {
1416
- const id = parseInt(predictionCloseMatch[1], 10);
1417
- const body = await parseJsonBody(req);
1418
- const state = body['state'];
1419
- if (!isJsonString(state) || !isSetMember(VALID_CLOSURE_STATES, state) || state === 'open') {
1420
- throw new HttpError(400, 'state is required and must be one of: closed | closed-unknown');
1421
- }
1422
- const actual = body['actual'];
1423
- let actualValue;
1424
- if (actual !== undefined && actual !== null) {
1425
- if (!isJsonNumber(actual) || !Number.isFinite(actual)) {
1426
- throw new HttpError(400, 'actual must be a finite number');
1427
- }
1428
- actualValue = actual;
1620
+ catch (e) {
1621
+ const msg = e instanceof Error ? e.message : String(e);
1622
+ if (msg.includes('not found')) {
1623
+ throw new HttpError(404, msg);
1429
1624
  }
1430
- const note = body['note'];
1431
- let closureNote;
1432
- if (note !== undefined && note !== null) {
1433
- if (!isJsonString(note)) {
1434
- throw new HttpError(400, 'note must be a string');
1435
- }
1436
- if (note.length > 2048) {
1437
- throw new HttpError(400, 'note exceeds 2048-character cap');
1438
- }
1439
- closureNote = note;
1625
+ if (msg.includes('already closed')) {
1626
+ throw new HttpError(409, msg);
1440
1627
  }
1441
- const ctx = await buildContextWithAuth(req, opts);
1442
- try {
1443
- const prediction = closePrediction(opts.hippoRoot, ctx.tenantId, id, {
1444
- closureState: state,
1445
- actualValue,
1446
- closureNote,
1447
- }, ctx.actor.subject);
1448
- sendJson(res, 200, { prediction });
1449
- }
1450
- catch (e) {
1451
- const msg = e instanceof Error ? e.message : String(e);
1452
- if (msg.includes('not found')) {
1453
- throw new HttpError(404, msg);
1454
- }
1455
- throw e;
1456
- }
1457
- return;
1628
+ throw e;
1458
1629
  }
1459
- // ── decisions (E2 first-class object) ──
1460
- //
1461
- // 5 routes: POST /v1/decisions (create, optional supersedesDecisionId),
1462
- // GET /v1/decisions (list, status filter), GET /v1/decisions/:id (show),
1463
- // POST /v1/decisions/:id/supersede (create a successor + supersede :id),
1464
- // POST /v1/decisions/:id/close (retire). Bearer-authed + tenant-scoped via
1465
- // buildContextWithAuth. status validated against VALID_DECISION_STATES.
1466
- // DoS caps: text 4096, context 4096 (v1.11.4 pattern). The HTTP surface is
1467
- // new (no legacy --supersedes <memory-id> constraint), so it supersedes by
1468
- // table id and never weakens a memory mirror.
1469
- if (method === 'POST' && path === '/v1/decisions') {
1470
- const body = await parseJsonBody(req);
1471
- const text = body['text'];
1472
- if (!isJsonString(text) || text.length === 0) {
1473
- throw new HttpError(400, 'text is required (non-empty string)');
1474
- }
1475
- if (text.length > 4096) {
1476
- throw new HttpError(400, 'text exceeds 4096-character cap');
1477
- }
1478
- const contextRaw = body['context'];
1479
- let context;
1480
- if (contextRaw !== undefined && contextRaw !== null) {
1481
- if (!isJsonString(contextRaw)) {
1482
- throw new HttpError(400, 'context must be a string');
1483
- }
1484
- if (contextRaw.length > 4096) {
1485
- throw new HttpError(400, 'context exceeds 4096-character cap');
1486
- }
1487
- context = contextRaw;
1488
- }
1489
- const supRaw = body['supersedesDecisionId'];
1490
- let supersedesDecisionId;
1491
- if (supRaw !== undefined && supRaw !== null) {
1492
- if (!isJsonNumber(supRaw) || !Number.isInteger(supRaw) || supRaw <= 0) {
1493
- throw new HttpError(400, 'supersedesDecisionId must be a positive integer');
1494
- }
1495
- supersedesDecisionId = supRaw;
1496
- }
1497
- const ctx = await buildContextWithAuth(req, opts);
1498
- try {
1499
- const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
1500
- decisionText: text,
1501
- context,
1502
- supersedesDecisionId,
1503
- }, ctx.actor.subject);
1504
- sendJson(res, 201, { decision });
1505
- }
1506
- catch (e) {
1507
- const msg = e instanceof Error ? e.message : String(e);
1508
- if (msg.includes('not found') || msg.includes('not active')) {
1509
- throw new HttpError(409, msg);
1510
- }
1511
- throw e;
1512
- }
1513
- return;
1630
+ return;
1631
+ }
1632
+ async function handleGetIncident({ req, res, opts }, incidentByIdMatch) {
1633
+ const id = parseInt(incidentByIdMatch[1], 10);
1634
+ const ctx = await buildContextWithAuth(req, opts);
1635
+ const incident = loadIncidentById(opts.hippoRoot, ctx.tenantId, id);
1636
+ if (!incident) {
1637
+ throw new HttpError(404, `incident ${id} not found`);
1638
+ }
1639
+ sendJson(res, 200, { incident });
1640
+ return;
1641
+ }
1642
+ // ── processes (E2 first-class object) ──
1643
+ //
1644
+ // 5 routes: POST /v1/processes (new; body processName + steps[] + description),
1645
+ // GET /v1/processes (list, status filter), GET /v1/processes/:id (show),
1646
+ // POST /v1/processes/:id/supersede (active -> superseded by a new version; body
1647
+ // steps[] + changeSummary + description; reuses the predecessor's name),
1648
+ // POST /v1/processes/:id/close (active -> closed). Bearer-authed + tenant-scoped
1649
+ // via buildContextWithAuth. status validated against VALID_PROCESS_STATES. DoS
1650
+ // caps: processName/description/changeSummary 4096, steps 200x2000
1651
+ // (validateProcessStepsBody). Mirrors /v1/decisions; the delta lifecycle is the
1652
+ // decision supersede path.
1653
+ async function handleCreateProcess({ req, res, opts }) {
1654
+ const body = await parseJsonBody(req);
1655
+ const processName = body['processName'];
1656
+ if (!isJsonString(processName) || processName.trim().length === 0) {
1657
+ throw new HttpError(400, 'processName is required (non-empty string)');
1658
+ }
1659
+ if (processName.length > 4096) {
1660
+ throw new HttpError(400, 'processName exceeds 4096-character cap');
1661
+ }
1662
+ const steps = validateProcessStepsBody(body['steps']);
1663
+ const descriptionRaw = body['description'];
1664
+ let description;
1665
+ if (descriptionRaw !== undefined && descriptionRaw !== null) {
1666
+ if (!isJsonString(descriptionRaw)) {
1667
+ throw new HttpError(400, 'description must be a string');
1668
+ }
1669
+ if (descriptionRaw.length > 4096) {
1670
+ throw new HttpError(400, 'description exceeds 4096-character cap');
1671
+ }
1672
+ description = descriptionRaw;
1673
+ }
1674
+ const ctx = await buildContextWithAuth(req, opts);
1675
+ const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1676
+ processName,
1677
+ steps,
1678
+ description,
1679
+ }, ctx.actor.subject);
1680
+ sendJson(res, 201, { process });
1681
+ return;
1682
+ }
1683
+ async function handleListProcesses({ req, res, opts, query }) {
1684
+ const status = query.get('status') ?? 'all';
1685
+ const limit = parseListLimit(query.get('limit'));
1686
+ const ctx = await buildContextWithAuth(req, opts);
1687
+ let processes;
1688
+ if (status === 'all') {
1689
+ processes = loadProcesses(opts.hippoRoot, ctx.tenantId, { limit });
1690
+ }
1691
+ else {
1692
+ if (!isSetMember(VALID_PROCESS_STATES, status)) {
1693
+ throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1694
+ }
1695
+ processes = loadProcesses(opts.hippoRoot, ctx.tenantId, {
1696
+ status,
1697
+ limit,
1698
+ });
1514
1699
  }
1515
- if (method === 'GET' && path === '/v1/decisions') {
1516
- const status = query.get('status') ?? 'all';
1517
- const limit = parseListLimit(query.get('limit'));
1518
- const ctx = await buildContextWithAuth(req, opts);
1519
- let decisions;
1520
- if (status === 'all') {
1521
- decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, { limit });
1700
+ sendJson(res, 200, { processes });
1701
+ return;
1702
+ }
1703
+ async function handleSupersedeProcess({ req, res, opts }, processSupersedeMatch) {
1704
+ const id = parseInt(processSupersedeMatch[1], 10);
1705
+ const body = await parseJsonBody(req);
1706
+ const steps = validateProcessStepsBody(body['steps']);
1707
+ if (steps.length === 0) {
1708
+ throw new HttpError(400, 'steps is required (at least one step) for a supersession');
1709
+ }
1710
+ const changeRaw = body['changeSummary'];
1711
+ let changeSummary;
1712
+ if (changeRaw !== undefined && changeRaw !== null) {
1713
+ if (!isJsonString(changeRaw)) {
1714
+ throw new HttpError(400, 'changeSummary must be a string');
1715
+ }
1716
+ if (changeRaw.length > 4096) {
1717
+ throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
1718
+ }
1719
+ changeSummary = changeRaw;
1720
+ }
1721
+ const descRaw = body['description'];
1722
+ let description;
1723
+ if (descRaw !== undefined && descRaw !== null) {
1724
+ if (!isJsonString(descRaw)) {
1725
+ throw new HttpError(400, 'description must be a string');
1726
+ }
1727
+ if (descRaw.length > 4096) {
1728
+ throw new HttpError(400, 'description exceeds 4096-character cap');
1729
+ }
1730
+ description = descRaw;
1731
+ }
1732
+ const ctx = await buildContextWithAuth(req, opts);
1733
+ // A supersession is a new version of the SAME process: reuse the
1734
+ // predecessor's name. 404 if the target does not exist; saveProcess's
1735
+ // in-SAVEPOINT preflight is the authoritative active-state check (409).
1736
+ const existing = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
1737
+ if (!existing) {
1738
+ throw new HttpError(404, `process ${id} not found`);
1739
+ }
1740
+ try {
1741
+ const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1742
+ processName: existing.processName,
1743
+ steps,
1744
+ description,
1745
+ changeSummary,
1746
+ supersedesProcessId: id,
1747
+ }, ctx.actor.subject);
1748
+ sendJson(res, 200, { process });
1749
+ }
1750
+ catch (e) {
1751
+ const msg = e instanceof Error ? e.message : String(e);
1752
+ if (msg.includes('not found')) {
1753
+ throw new HttpError(404, msg);
1522
1754
  }
1523
- else {
1524
- if (!isSetMember(VALID_DECISION_STATES, status)) {
1525
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1526
- }
1527
- decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, {
1528
- status,
1529
- limit,
1530
- });
1755
+ if (msg.includes('not active') || msg.includes('could not be superseded')) {
1756
+ throw new HttpError(409, msg);
1531
1757
  }
1532
- sendJson(res, 200, { decisions });
1533
- return;
1758
+ throw e;
1534
1759
  }
1535
- const decisionSupersedeMatch = path.match(/^\/v1\/decisions\/(\d+)\/supersede$/);
1536
- if (method === 'POST' && decisionSupersedeMatch) {
1537
- const oldId = parseInt(decisionSupersedeMatch[1], 10);
1538
- const body = await parseJsonBody(req);
1539
- const text = body['text'];
1540
- if (!isJsonString(text) || text.length === 0) {
1541
- throw new HttpError(400, 'text is required (non-empty string)');
1542
- }
1543
- if (text.length > 4096) {
1544
- throw new HttpError(400, 'text exceeds 4096-character cap');
1545
- }
1546
- const contextRaw = body['context'];
1547
- let context;
1548
- if (contextRaw !== undefined && contextRaw !== null) {
1549
- if (!isJsonString(contextRaw)) {
1550
- throw new HttpError(400, 'context must be a string');
1551
- }
1552
- if (contextRaw.length > 4096) {
1553
- throw new HttpError(400, 'context exceeds 4096-character cap');
1554
- }
1555
- context = contextRaw;
1760
+ return;
1761
+ }
1762
+ async function handleCloseProcess({ req, res, opts }, processCloseMatch) {
1763
+ const id = parseInt(processCloseMatch[1], 10);
1764
+ const ctx = await buildContextWithAuth(req, opts);
1765
+ try {
1766
+ const process = closeProcess(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1767
+ sendJson(res, 200, { process });
1768
+ }
1769
+ catch (e) {
1770
+ const msg = e instanceof Error ? e.message : String(e);
1771
+ if (msg.includes('not found')) {
1772
+ throw new HttpError(404, msg);
1556
1773
  }
1557
- const ctx = await buildContextWithAuth(req, opts);
1558
- try {
1559
- const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
1560
- decisionText: text,
1561
- context,
1562
- supersedesDecisionId: oldId,
1563
- }, ctx.actor.subject);
1564
- sendJson(res, 201, { decision });
1565
- }
1566
- catch (e) {
1567
- const msg = e instanceof Error ? e.message : String(e);
1568
- if (msg.includes('not found')) {
1569
- throw new HttpError(404, msg);
1570
- }
1571
- if (msg.includes('not active')) {
1572
- throw new HttpError(409, msg);
1573
- }
1574
- throw e;
1774
+ if (msg.includes('not active')) {
1775
+ throw new HttpError(409, msg);
1575
1776
  }
1576
- return;
1777
+ throw e;
1577
1778
  }
1578
- const decisionCloseMatch = path.match(/^\/v1\/decisions\/(\d+)\/close$/);
1579
- if (method === 'POST' && decisionCloseMatch) {
1580
- const id = parseInt(decisionCloseMatch[1], 10);
1581
- const ctx = await buildContextWithAuth(req, opts);
1582
- try {
1583
- const decision = closeDecision(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1584
- sendJson(res, 200, { decision });
1779
+ return;
1780
+ }
1781
+ async function handleGetProcess({ req, res, opts }, processByIdMatch) {
1782
+ const id = parseInt(processByIdMatch[1], 10);
1783
+ const ctx = await buildContextWithAuth(req, opts);
1784
+ const process = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
1785
+ if (!process) {
1786
+ throw new HttpError(404, `process ${id} not found`);
1787
+ }
1788
+ sendJson(res, 200, { process });
1789
+ return;
1790
+ }
1791
+ // ── policies (E2 first-class object, bi-temporal-first) ──
1792
+ //
1793
+ // 6 routes: POST /v1/policies (new; processName-style body policyName +
1794
+ // policyText + validFrom? + validTo?), GET /v1/policies (list, status filter),
1795
+ // GET /v1/policies/asof (date + optional name; the bi-temporal as-of query;
1796
+ // placed BEFORE the /:id GET so the literal 'asof' is matched first), GET
1797
+ // /v1/policies/:id, POST /v1/policies/:id/supersede, POST /v1/policies/:id/close.
1798
+ // Date inputs are normalized + range-validated in the store; an invalid/inverted
1799
+ // date throws -> 400. DoS caps: policyName/policyText/changeSummary 4096.
1800
+ async function handleCreatePolicy({ req, res, opts }) {
1801
+ const body = await parseJsonBody(req);
1802
+ const policyName = body['policyName'];
1803
+ if (!isJsonString(policyName) || policyName.trim().length === 0) {
1804
+ throw new HttpError(400, 'policyName is required (non-empty string)');
1805
+ }
1806
+ if (policyName.length > 4096) {
1807
+ throw new HttpError(400, 'policyName exceeds 4096-character cap');
1808
+ }
1809
+ const policyText = body['policyText'];
1810
+ if (!isJsonString(policyText) || policyText.trim().length === 0) {
1811
+ throw new HttpError(400, 'policyText is required (non-empty string)');
1812
+ }
1813
+ if (policyText.length > 4096) {
1814
+ throw new HttpError(400, 'policyText exceeds 4096-character cap');
1815
+ }
1816
+ const validFrom = optionalDateField(body['validFrom'], 'validFrom');
1817
+ const validTo = optionalDateField(body['validTo'], 'validTo');
1818
+ const ctx = await buildContextWithAuth(req, opts);
1819
+ try {
1820
+ const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1821
+ policyName,
1822
+ policyText,
1823
+ validFrom,
1824
+ validTo,
1825
+ }, ctx.actor.subject);
1826
+ sendJson(res, 201, { policy });
1827
+ }
1828
+ catch (e) {
1829
+ // savePolicy throws on invalid/inverted dates (validation) -> 400.
1830
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
1831
+ }
1832
+ return;
1833
+ }
1834
+ async function handleListPolicies({ req, res, opts, query }) {
1835
+ const status = query.get('status') ?? 'all';
1836
+ const limit = parseListLimit(query.get('limit'));
1837
+ const ctx = await buildContextWithAuth(req, opts);
1838
+ let policies;
1839
+ if (status === 'all') {
1840
+ policies = loadPolicies(opts.hippoRoot, ctx.tenantId, { limit });
1841
+ }
1842
+ else {
1843
+ if (!isSetMember(VALID_POLICY_STATES, status)) {
1844
+ throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1845
+ }
1846
+ policies = loadPolicies(opts.hippoRoot, ctx.tenantId, {
1847
+ status,
1848
+ limit,
1849
+ });
1850
+ }
1851
+ sendJson(res, 200, { policies });
1852
+ return;
1853
+ }
1854
+ // The as-of query: must precede the /:id GET (literal 'asof' is non-numeric so
1855
+ // the /(\d+)/ route would not match it, but order it first for clarity).
1856
+ async function handlePoliciesAsOf({ req, res, opts, query }) {
1857
+ const date = query.get('date');
1858
+ if (date === null || date.length === 0) {
1859
+ throw new HttpError(400, 'date is required (ISO-8601 valid-time)');
1860
+ }
1861
+ const name = query.get('name') ?? undefined;
1862
+ const ctx = await buildContextWithAuth(req, opts);
1863
+ try {
1864
+ const policies = loadPoliciesAsOf(opts.hippoRoot, ctx.tenantId, date, { name });
1865
+ sendJson(res, 200, { policies });
1866
+ }
1867
+ catch (e) {
1868
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
1869
+ }
1870
+ return;
1871
+ }
1872
+ async function handleSupersedePolicy({ req, res, opts }, policySupersedeMatch) {
1873
+ const id = parseInt(policySupersedeMatch[1], 10);
1874
+ const body = await parseJsonBody(req);
1875
+ const policyText = body['policyText'];
1876
+ if (!isJsonString(policyText) || policyText.trim().length === 0) {
1877
+ throw new HttpError(400, 'policyText is required (non-empty string)');
1878
+ }
1879
+ if (policyText.length > 4096) {
1880
+ throw new HttpError(400, 'policyText exceeds 4096-character cap');
1881
+ }
1882
+ const validFrom = optionalDateField(body['validFrom'], 'validFrom');
1883
+ const validTo = optionalDateField(body['validTo'], 'validTo');
1884
+ const changeRaw = body['changeSummary'];
1885
+ let changeSummary;
1886
+ if (changeRaw !== undefined && changeRaw !== null) {
1887
+ if (!isJsonString(changeRaw)) {
1888
+ throw new HttpError(400, 'changeSummary must be a string');
1889
+ }
1890
+ if (changeRaw.length > 4096) {
1891
+ throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
1892
+ }
1893
+ changeSummary = changeRaw;
1894
+ }
1895
+ const ctx = await buildContextWithAuth(req, opts);
1896
+ const existing = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
1897
+ if (!existing) {
1898
+ throw new HttpError(404, `policy ${id} not found`);
1899
+ }
1900
+ try {
1901
+ const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1902
+ policyName: existing.policyName,
1903
+ policyText,
1904
+ validFrom,
1905
+ validTo,
1906
+ changeSummary,
1907
+ supersedesPolicyId: id,
1908
+ }, ctx.actor.subject);
1909
+ sendJson(res, 200, { policy });
1910
+ }
1911
+ catch (e) {
1912
+ const msg = e instanceof Error ? e.message : String(e);
1913
+ if (msg.includes('not found')) {
1914
+ throw new HttpError(404, msg);
1585
1915
  }
1586
- catch (e) {
1587
- const msg = e instanceof Error ? e.message : String(e);
1588
- if (msg.includes('not found')) {
1589
- throw new HttpError(404, msg);
1590
- }
1591
- if (msg.includes('not active')) {
1592
- throw new HttpError(409, msg);
1593
- }
1594
- throw e;
1916
+ if (msg.includes('not active') || msg.includes('could not be superseded')) {
1917
+ throw new HttpError(409, msg);
1595
1918
  }
1596
- return;
1919
+ // invalid/inverted date or missing field -> validation.
1920
+ throw new HttpError(400, msg);
1597
1921
  }
1598
- const decisionByIdMatch = path.match(/^\/v1\/decisions\/(\d+)$/);
1599
- if (method === 'GET' && decisionByIdMatch) {
1600
- const id = parseInt(decisionByIdMatch[1], 10);
1601
- const ctx = await buildContextWithAuth(req, opts);
1602
- const decision = loadDecisionById(opts.hippoRoot, ctx.tenantId, id);
1603
- if (!decision) {
1604
- throw new HttpError(404, `decision ${id} not found`);
1605
- }
1606
- sendJson(res, 200, { decision });
1607
- return;
1922
+ return;
1923
+ }
1924
+ async function handleClosePolicy({ req, res, opts }, policyCloseMatch) {
1925
+ const id = parseInt(policyCloseMatch[1], 10);
1926
+ const ctx = await buildContextWithAuth(req, opts);
1927
+ try {
1928
+ const policy = closePolicy(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1929
+ sendJson(res, 200, { policy });
1608
1930
  }
1609
- // ── incidents (E2 first-class object) ──
1610
- //
1611
- // 5 routes: POST /v1/incidents (open; body text + context + linkedMemoryIds[]),
1612
- // GET /v1/incidents (list, status filter), GET /v1/incidents/:id (show),
1613
- // POST /v1/incidents/:id/resolve (open -> resolved; body resolutionText),
1614
- // POST /v1/incidents/:id/close (open|resolved -> closed). Bearer-authed +
1615
- // tenant-scoped via buildContextWithAuth. status validated against
1616
- // VALID_INCIDENT_STATES. DoS caps: text 4096, context 4096, resolutionText
1617
- // 4096 (v1.11.4 pattern). Mirrors /v1/decisions; lifecycle is
1618
- // open->resolved->closed (no supersede), so linkedMemoryIds replaces
1619
- // supersedesDecisionId on create.
1620
- if (method === 'POST' && path === '/v1/incidents') {
1621
- const body = await parseJsonBody(req);
1622
- const text = body['text'];
1623
- if (!isJsonString(text) || text.length === 0) {
1624
- throw new HttpError(400, 'text is required (non-empty string)');
1625
- }
1626
- if (text.length > 4096) {
1627
- throw new HttpError(400, 'text exceeds 4096-character cap');
1628
- }
1629
- const contextRaw = body['context'];
1630
- let context;
1631
- if (contextRaw !== undefined && contextRaw !== null) {
1632
- if (!isJsonString(contextRaw)) {
1633
- throw new HttpError(400, 'context must be a string');
1634
- }
1635
- if (contextRaw.length > 4096) {
1636
- throw new HttpError(400, 'context exceeds 4096-character cap');
1637
- }
1638
- context = contextRaw;
1639
- }
1640
- const linkedRaw = body['linkedMemoryIds'];
1641
- let linkedMemoryIds;
1642
- if (linkedRaw !== undefined && linkedRaw !== null) {
1643
- if (!Array.isArray(linkedRaw)) {
1644
- throw new HttpError(400, 'linkedMemoryIds must be an array of memory ids');
1645
- }
1646
- if (linkedRaw.length > 256) {
1647
- throw new HttpError(400, 'linkedMemoryIds exceeds 256-item cap');
1648
- }
1649
- const isValidMemoryId = (item) => isJsonString(item) && item.length > 0 && item.length <= 4096;
1650
- if (!linkedRaw.every(isValidMemoryId)) {
1651
- throw new HttpError(400, 'each linkedMemoryIds entry must be a non-empty string <= 4096 chars');
1652
- }
1653
- linkedMemoryIds = linkedRaw;
1931
+ catch (e) {
1932
+ const msg = e instanceof Error ? e.message : String(e);
1933
+ if (msg.includes('not found')) {
1934
+ throw new HttpError(404, msg);
1654
1935
  }
1655
- const ctx = await buildContextWithAuth(req, opts);
1656
- try {
1657
- const incident = saveIncident(opts.hippoRoot, ctx.tenantId, {
1658
- incidentText: text,
1659
- context,
1660
- linkedMemoryIds,
1661
- }, ctx.actor.subject);
1662
- sendJson(res, 201, { incident });
1663
- }
1664
- catch (e) {
1665
- const msg = e instanceof Error ? e.message : String(e);
1666
- if (msg.includes('not found')) {
1667
- throw new HttpError(409, msg);
1668
- }
1669
- throw e;
1936
+ if (msg.includes('not active')) {
1937
+ throw new HttpError(409, msg);
1670
1938
  }
1671
- return;
1939
+ throw e;
1672
1940
  }
1673
- if (method === 'GET' && path === '/v1/incidents') {
1674
- const status = query.get('status') ?? 'all';
1675
- const limit = parseListLimit(query.get('limit'));
1676
- const ctx = await buildContextWithAuth(req, opts);
1677
- let incidents;
1678
- if (status === 'all') {
1679
- incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, { limit });
1941
+ return;
1942
+ }
1943
+ async function handleGetPolicy({ req, res, opts }, policyByIdMatch) {
1944
+ const id = parseInt(policyByIdMatch[1], 10);
1945
+ const ctx = await buildContextWithAuth(req, opts);
1946
+ const policy = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
1947
+ if (!policy) {
1948
+ throw new HttpError(404, `policy ${id} not found`);
1949
+ }
1950
+ sendJson(res, 200, { policy });
1951
+ return;
1952
+ }
1953
+ // ── skills (E2 first-class object, executable/exportable) ──
1954
+ //
1955
+ // 6 routes: POST /v1/skills (new; body skillName + instructions + trigger?),
1956
+ // GET /v1/skills (list, status filter; shared parseListLimit), GET
1957
+ // /v1/skills/export (renders ACTIVE skills as an AGENTS.md/CLAUDE.md markdown
1958
+ // block -> {markdown}; literal 'export' is non-numeric so the /:id (\d+) route
1959
+ // cannot capture it, but it is ordered first regardless), GET /v1/skills/:id,
1960
+ // POST /v1/skills/:id/supersede, POST /v1/skills/:id/close. DoS caps:
1961
+ // skillName 256, instructions 8192, trigger 1024, changeSummary 4096. The store
1962
+ // validates + throws; the boundary maps validation -> 400, not-found -> 404,
1963
+ // not-active -> 409. Mirrors /v1/processes; "executable" = exportable
1964
+ // instruction (no code exec).
1965
+ async function handleCreateSkill({ req, res, opts }) {
1966
+ const body = await parseJsonBody(req);
1967
+ const skillName = body['skillName'];
1968
+ if (!isJsonString(skillName) || skillName.trim().length === 0) {
1969
+ throw new HttpError(400, 'skillName is required (non-empty string)');
1970
+ }
1971
+ if (skillName.length > 256) {
1972
+ throw new HttpError(400, 'skillName exceeds 256-character cap');
1973
+ }
1974
+ const instructions = body['instructions'];
1975
+ if (!isJsonString(instructions) || instructions.trim().length === 0) {
1976
+ throw new HttpError(400, 'instructions are required (non-empty string)');
1977
+ }
1978
+ if (instructions.length > 8192) {
1979
+ throw new HttpError(400, 'instructions exceed 8192-character cap');
1980
+ }
1981
+ const triggerRaw = body['trigger'];
1982
+ let trigger;
1983
+ if (triggerRaw !== undefined && triggerRaw !== null) {
1984
+ if (!isJsonString(triggerRaw)) {
1985
+ throw new HttpError(400, 'trigger must be a string');
1986
+ }
1987
+ if (triggerRaw.length > 1024) {
1988
+ throw new HttpError(400, 'trigger exceeds 1024-character cap');
1989
+ }
1990
+ trigger = triggerRaw;
1991
+ }
1992
+ const ctx = await buildContextWithAuth(req, opts);
1993
+ try {
1994
+ const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
1995
+ skillName,
1996
+ instructions,
1997
+ trigger,
1998
+ }, ctx.actor.subject);
1999
+ sendJson(res, 201, { skill });
2000
+ }
2001
+ catch (e) {
2002
+ // saveSkill throws on validation (single-line name etc.) -> 400.
2003
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
2004
+ }
2005
+ return;
2006
+ }
2007
+ async function handleListSkills({ req, res, opts, query }) {
2008
+ const status = query.get('status') ?? 'all';
2009
+ const limit = parseListLimit(query.get('limit'));
2010
+ const ctx = await buildContextWithAuth(req, opts);
2011
+ let skills;
2012
+ if (status === 'all') {
2013
+ skills = loadSkills(opts.hippoRoot, ctx.tenantId, { limit });
2014
+ }
2015
+ else {
2016
+ if (!isSetMember(VALID_SKILL_STATES, status)) {
2017
+ throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2018
+ }
2019
+ skills = loadSkills(opts.hippoRoot, ctx.tenantId, {
2020
+ status,
2021
+ limit,
2022
+ });
2023
+ }
2024
+ sendJson(res, 200, { skills });
2025
+ return;
2026
+ }
2027
+ // The export renderer: must precede the /:id GET (literal 'export' is
2028
+ // non-numeric so the /(\d+)/ route would not match it, but order it first).
2029
+ async function handleExportSkills({ req, res, opts }) {
2030
+ const ctx = await buildContextWithAuth(req, opts);
2031
+ const markdown = exportSkills(opts.hippoRoot, ctx.tenantId);
2032
+ sendJson(res, 200, { markdown });
2033
+ return;
2034
+ }
2035
+ async function handleSupersedeSkill({ req, res, opts }, skillSupersedeMatch) {
2036
+ const id = parseInt(skillSupersedeMatch[1], 10);
2037
+ const body = await parseJsonBody(req);
2038
+ const instructions = body['instructions'];
2039
+ if (!isJsonString(instructions) || instructions.trim().length === 0) {
2040
+ throw new HttpError(400, 'instructions are required (non-empty string)');
2041
+ }
2042
+ if (instructions.length > 8192) {
2043
+ throw new HttpError(400, 'instructions exceed 8192-character cap');
2044
+ }
2045
+ const triggerRaw = body['trigger'];
2046
+ let trigger;
2047
+ if (triggerRaw !== undefined && triggerRaw !== null) {
2048
+ if (!isJsonString(triggerRaw)) {
2049
+ throw new HttpError(400, 'trigger must be a string');
1680
2050
  }
1681
- else {
1682
- if (!isSetMember(VALID_INCIDENT_STATES, status)) {
1683
- throw new HttpError(400, `status must be one of: open | resolved | closed | all (got "${status}")`);
1684
- }
1685
- incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, {
1686
- status,
1687
- limit,
1688
- });
2051
+ if (triggerRaw.length > 1024) {
2052
+ throw new HttpError(400, 'trigger exceeds 1024-character cap');
1689
2053
  }
1690
- sendJson(res, 200, { incidents });
1691
- return;
2054
+ trigger = triggerRaw;
1692
2055
  }
1693
- const incidentResolveMatch = path.match(/^\/v1\/incidents\/(\d+)\/resolve$/);
1694
- if (method === 'POST' && incidentResolveMatch) {
1695
- const id = parseInt(incidentResolveMatch[1], 10);
1696
- const body = await parseJsonBody(req);
1697
- const resolutionText = body['resolutionText'];
1698
- if (!isJsonString(resolutionText) || resolutionText.trim().length === 0) {
1699
- throw new HttpError(400, 'resolutionText is required (non-empty string)');
2056
+ const changeRaw = body['changeSummary'];
2057
+ let changeSummary;
2058
+ if (changeRaw !== undefined && changeRaw !== null) {
2059
+ if (!isJsonString(changeRaw)) {
2060
+ throw new HttpError(400, 'changeSummary must be a string');
1700
2061
  }
1701
- if (resolutionText.length > 4096) {
1702
- throw new HttpError(400, 'resolutionText exceeds 4096-character cap');
2062
+ if (changeRaw.length > 4096) {
2063
+ throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
1703
2064
  }
1704
- const ctx = await buildContextWithAuth(req, opts);
1705
- try {
1706
- const incident = resolveIncident(opts.hippoRoot, ctx.tenantId, id, resolutionText, ctx.actor.subject);
1707
- sendJson(res, 200, { incident });
2065
+ changeSummary = changeRaw;
2066
+ }
2067
+ const ctx = await buildContextWithAuth(req, opts);
2068
+ const existing = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
2069
+ if (!existing) {
2070
+ throw new HttpError(404, `skill ${id} not found`);
2071
+ }
2072
+ try {
2073
+ const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
2074
+ skillName: existing.skillName,
2075
+ instructions,
2076
+ trigger,
2077
+ changeSummary,
2078
+ supersedesSkillId: id,
2079
+ }, ctx.actor.subject);
2080
+ sendJson(res, 200, { skill });
2081
+ }
2082
+ catch (e) {
2083
+ const msg = e instanceof Error ? e.message : String(e);
2084
+ if (msg.includes('not found')) {
2085
+ throw new HttpError(404, msg);
1708
2086
  }
1709
- catch (e) {
1710
- const msg = e instanceof Error ? e.message : String(e);
1711
- if (msg.includes('not found')) {
1712
- throw new HttpError(404, msg);
1713
- }
1714
- if (msg.includes('not open')) {
1715
- throw new HttpError(409, msg);
1716
- }
1717
- throw e;
2087
+ if (msg.includes('not active') || msg.includes('could not be superseded')) {
2088
+ throw new HttpError(409, msg);
1718
2089
  }
1719
- return;
2090
+ throw new HttpError(400, msg);
1720
2091
  }
1721
- const incidentCloseMatch = path.match(/^\/v1\/incidents\/(\d+)\/close$/);
1722
- if (method === 'POST' && incidentCloseMatch) {
1723
- const id = parseInt(incidentCloseMatch[1], 10);
1724
- const ctx = await buildContextWithAuth(req, opts);
1725
- try {
1726
- const incident = closeIncident(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1727
- sendJson(res, 200, { incident });
2092
+ return;
2093
+ }
2094
+ async function handleCloseSkill({ req, res, opts }, skillCloseMatch) {
2095
+ const id = parseInt(skillCloseMatch[1], 10);
2096
+ const ctx = await buildContextWithAuth(req, opts);
2097
+ try {
2098
+ const skill = closeSkill(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2099
+ sendJson(res, 200, { skill });
2100
+ }
2101
+ catch (e) {
2102
+ const msg = e instanceof Error ? e.message : String(e);
2103
+ if (msg.includes('not found')) {
2104
+ throw new HttpError(404, msg);
1728
2105
  }
1729
- catch (e) {
1730
- const msg = e instanceof Error ? e.message : String(e);
1731
- if (msg.includes('not found')) {
1732
- throw new HttpError(404, msg);
1733
- }
1734
- if (msg.includes('already closed')) {
1735
- throw new HttpError(409, msg);
1736
- }
1737
- throw e;
2106
+ if (msg.includes('not active')) {
2107
+ throw new HttpError(409, msg);
1738
2108
  }
1739
- return;
2109
+ throw e;
1740
2110
  }
1741
- const incidentByIdMatch = path.match(/^\/v1\/incidents\/(\d+)$/);
1742
- if (method === 'GET' && incidentByIdMatch) {
1743
- const id = parseInt(incidentByIdMatch[1], 10);
1744
- const ctx = await buildContextWithAuth(req, opts);
1745
- const incident = loadIncidentById(opts.hippoRoot, ctx.tenantId, id);
1746
- if (!incident) {
1747
- throw new HttpError(404, `incident ${id} not found`);
1748
- }
1749
- sendJson(res, 200, { incident });
1750
- return;
2111
+ return;
2112
+ }
2113
+ async function handleGetSkill({ req, res, opts }, skillByIdMatch) {
2114
+ const id = parseInt(skillByIdMatch[1], 10);
2115
+ const ctx = await buildContextWithAuth(req, opts);
2116
+ const skill = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
2117
+ if (!skill) {
2118
+ throw new HttpError(404, `skill ${id} not found`);
2119
+ }
2120
+ sendJson(res, 200, { skill });
2121
+ return;
2122
+ }
2123
+ // ── E2 project_brief routes ──
2124
+ //
2125
+ // 6 routes: POST /v1/project-briefs (new; body repo + summary), GET
2126
+ // /v1/project-briefs (list; status + repo filter; shared parseListLimit), POST
2127
+ // /v1/project-briefs/refresh (body {repo, dryRun?} -> auto-assemble the brief
2128
+ // from the repo's receipts; dryRun returns {markdown} without writing; ordered
2129
+ // before /:id), GET /v1/project-briefs/:id, POST /v1/project-briefs/:id/supersede,
2130
+ // POST /v1/project-briefs/:id/close. DoS caps: repo 256, summary 8192,
2131
+ // changeSummary 4096. The store validates + throws; the boundary maps validation
2132
+ // -> 400, not-found -> 404, not-active -> 409. Mirrors /v1/skills.
2133
+ async function handleCreateProjectBrief({ req, res, opts }) {
2134
+ const body = await parseJsonBody(req);
2135
+ const repo = body['repo'];
2136
+ if (!isJsonString(repo) || repo.trim().length === 0) {
2137
+ throw new HttpError(400, 'repo is required (non-empty string)');
2138
+ }
2139
+ if (repo.length > 256) {
2140
+ throw new HttpError(400, 'repo exceeds 256-character cap');
2141
+ }
2142
+ const summary = body['summary'];
2143
+ if (!isJsonString(summary) || summary.trim().length === 0) {
2144
+ throw new HttpError(400, 'summary is required (non-empty string)');
2145
+ }
2146
+ if (summary.length > 8192) {
2147
+ throw new HttpError(400, 'summary exceeds 8192-character cap');
2148
+ }
2149
+ const ctx = await buildContextWithAuth(req, opts);
2150
+ try {
2151
+ const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2152
+ repo,
2153
+ summary,
2154
+ }, ctx.actor.subject);
2155
+ sendJson(res, 201, { brief });
1751
2156
  }
1752
- // ── processes (E2 first-class object) ──
1753
- //
1754
- // 5 routes: POST /v1/processes (new; body processName + steps[] + description),
1755
- // GET /v1/processes (list, status filter), GET /v1/processes/:id (show),
1756
- // POST /v1/processes/:id/supersede (active -> superseded by a new version; body
1757
- // steps[] + changeSummary + description; reuses the predecessor's name),
1758
- // POST /v1/processes/:id/close (active -> closed). Bearer-authed + tenant-scoped
1759
- // via buildContextWithAuth. status validated against VALID_PROCESS_STATES. DoS
1760
- // caps: processName/description/changeSummary 4096, steps 200x2000
1761
- // (validateProcessStepsBody). Mirrors /v1/decisions; the delta lifecycle is the
1762
- // decision supersede path.
1763
- if (method === 'POST' && path === '/v1/processes') {
1764
- const body = await parseJsonBody(req);
1765
- const processName = body['processName'];
1766
- if (!isJsonString(processName) || processName.trim().length === 0) {
1767
- throw new HttpError(400, 'processName is required (non-empty string)');
1768
- }
1769
- if (processName.length > 4096) {
1770
- throw new HttpError(400, 'processName exceeds 4096-character cap');
1771
- }
1772
- const steps = validateProcessStepsBody(body['steps']);
1773
- const descriptionRaw = body['description'];
1774
- let description;
1775
- if (descriptionRaw !== undefined && descriptionRaw !== null) {
1776
- if (!isJsonString(descriptionRaw)) {
1777
- throw new HttpError(400, 'description must be a string');
1778
- }
1779
- if (descriptionRaw.length > 4096) {
1780
- throw new HttpError(400, 'description exceeds 4096-character cap');
1781
- }
1782
- description = descriptionRaw;
2157
+ catch (e) {
2158
+ // saveProjectBrief throws on validation (single-line repo etc.) -> 400.
2159
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
2160
+ }
2161
+ return;
2162
+ }
2163
+ async function handleListProjectBriefs({ req, res, opts, query }) {
2164
+ const status = query.get('status') ?? 'all';
2165
+ const repoFilter = query.get('repo');
2166
+ const limit = parseListLimit(query.get('limit'));
2167
+ const ctx = await buildContextWithAuth(req, opts);
2168
+ const listOpts = { limit };
2169
+ if (repoFilter !== null && repoFilter.trim().length > 0) {
2170
+ listOpts.repo = repoFilter.trim();
2171
+ }
2172
+ if (status !== 'all') {
2173
+ if (!isSetMember(VALID_BRIEF_STATES, status)) {
2174
+ throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2175
+ }
2176
+ listOpts.status = status;
2177
+ }
2178
+ const briefs = loadProjectBriefs(opts.hippoRoot, ctx.tenantId, listOpts);
2179
+ sendJson(res, 200, { briefs });
2180
+ return;
2181
+ }
2182
+ // The refresh op: must precede the /:id routes (literal 'refresh' is non-numeric
2183
+ // so the /(\d+)/ routes would not match it, but order it first).
2184
+ async function handleRefreshProjectBrief({ req, res, opts }) {
2185
+ const body = await parseJsonBody(req);
2186
+ const repo = body['repo'];
2187
+ if (!isJsonString(repo) || repo.trim().length === 0) {
2188
+ throw new HttpError(400, 'repo is required (non-empty string)');
2189
+ }
2190
+ if (repo.length > 256) {
2191
+ throw new HttpError(400, 'repo exceeds 256-character cap');
2192
+ }
2193
+ const dryRun = body['dryRun'] === true;
2194
+ const ctx = await buildContextWithAuth(req, opts);
2195
+ try {
2196
+ if (dryRun) {
2197
+ const { markdown, receiptCount } = assembleBriefFromReceipts(opts.hippoRoot, ctx.tenantId, repo);
2198
+ sendJson(res, 200, { markdown, receiptCount });
2199
+ return;
1783
2200
  }
1784
- const ctx = await buildContextWithAuth(req, opts);
1785
- const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1786
- processName,
1787
- steps,
1788
- description,
1789
- }, ctx.actor.subject);
1790
- sendJson(res, 201, { process });
1791
- return;
2201
+ const brief = refreshBrief(opts.hippoRoot, ctx.tenantId, repo, ctx.actor.subject);
2202
+ sendJson(res, 200, { brief });
1792
2203
  }
1793
- if (method === 'GET' && path === '/v1/processes') {
1794
- const status = query.get('status') ?? 'all';
1795
- const limit = parseListLimit(query.get('limit'));
1796
- const ctx = await buildContextWithAuth(req, opts);
1797
- let processes;
1798
- if (status === 'all') {
1799
- processes = loadProcesses(opts.hippoRoot, ctx.tenantId, { limit });
2204
+ catch (e) {
2205
+ // A refresh race (the active brief is closed/superseded between
2206
+ // loadActiveBriefForRepo and the supersede CAS) is a state conflict, not a
2207
+ // validation error — map it to 409 like the explicit supersede route
2208
+ // (codex-review 2026-05-30, P3).
2209
+ const msg = e instanceof Error ? e.message : String(e);
2210
+ if (msg.includes('not found')) {
2211
+ throw new HttpError(404, msg);
1800
2212
  }
1801
- else {
1802
- if (!isSetMember(VALID_PROCESS_STATES, status)) {
1803
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1804
- }
1805
- processes = loadProcesses(opts.hippoRoot, ctx.tenantId, {
1806
- status,
1807
- limit,
1808
- });
2213
+ if (msg.includes('not active') || msg.includes('could not be superseded')) {
2214
+ throw new HttpError(409, msg);
1809
2215
  }
1810
- sendJson(res, 200, { processes });
1811
- return;
2216
+ throw new HttpError(400, msg);
1812
2217
  }
1813
- const processSupersedeMatch = path.match(/^\/v1\/processes\/(\d+)\/supersede$/);
1814
- if (method === 'POST' && processSupersedeMatch) {
1815
- const id = parseInt(processSupersedeMatch[1], 10);
1816
- const body = await parseJsonBody(req);
1817
- const steps = validateProcessStepsBody(body['steps']);
1818
- if (steps.length === 0) {
1819
- throw new HttpError(400, 'steps is required (at least one step) for a supersession');
1820
- }
1821
- const changeRaw = body['changeSummary'];
1822
- let changeSummary;
1823
- if (changeRaw !== undefined && changeRaw !== null) {
1824
- if (!isJsonString(changeRaw)) {
1825
- throw new HttpError(400, 'changeSummary must be a string');
1826
- }
1827
- if (changeRaw.length > 4096) {
1828
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
1829
- }
1830
- changeSummary = changeRaw;
2218
+ return;
2219
+ }
2220
+ async function handleSupersedeProjectBrief({ req, res, opts }, briefSupersedeMatch) {
2221
+ const id = parseInt(briefSupersedeMatch[1], 10);
2222
+ const body = await parseJsonBody(req);
2223
+ const summary = body['summary'];
2224
+ if (!isJsonString(summary) || summary.trim().length === 0) {
2225
+ throw new HttpError(400, 'summary is required (non-empty string)');
2226
+ }
2227
+ if (summary.length > 8192) {
2228
+ throw new HttpError(400, 'summary exceeds 8192-character cap');
2229
+ }
2230
+ const changeRaw = body['changeSummary'];
2231
+ let changeSummary;
2232
+ if (changeRaw !== undefined && changeRaw !== null) {
2233
+ if (!isJsonString(changeRaw)) {
2234
+ throw new HttpError(400, 'changeSummary must be a string');
1831
2235
  }
1832
- const descRaw = body['description'];
1833
- let description;
1834
- if (descRaw !== undefined && descRaw !== null) {
1835
- if (!isJsonString(descRaw)) {
1836
- throw new HttpError(400, 'description must be a string');
1837
- }
1838
- if (descRaw.length > 4096) {
1839
- throw new HttpError(400, 'description exceeds 4096-character cap');
1840
- }
1841
- description = descRaw;
2236
+ if (changeRaw.length > 4096) {
2237
+ throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
1842
2238
  }
1843
- const ctx = await buildContextWithAuth(req, opts);
1844
- // A supersession is a new version of the SAME process: reuse the
1845
- // predecessor's name. 404 if the target does not exist; saveProcess's
1846
- // in-SAVEPOINT preflight is the authoritative active-state check (409).
1847
- const existing = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
1848
- if (!existing) {
1849
- throw new HttpError(404, `process ${id} not found`);
2239
+ changeSummary = changeRaw;
2240
+ }
2241
+ const ctx = await buildContextWithAuth(req, opts);
2242
+ const existing = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
2243
+ if (!existing) {
2244
+ throw new HttpError(404, `project brief ${id} not found`);
2245
+ }
2246
+ try {
2247
+ const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2248
+ repo: existing.repo,
2249
+ summary,
2250
+ changeSummary,
2251
+ supersedesBriefId: id,
2252
+ }, ctx.actor.subject);
2253
+ sendJson(res, 200, { brief });
2254
+ }
2255
+ catch (e) {
2256
+ const msg = e instanceof Error ? e.message : String(e);
2257
+ if (msg.includes('not found')) {
2258
+ throw new HttpError(404, msg);
1850
2259
  }
1851
- try {
1852
- const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1853
- processName: existing.processName,
1854
- steps,
1855
- description,
1856
- changeSummary,
1857
- supersedesProcessId: id,
1858
- }, ctx.actor.subject);
1859
- sendJson(res, 200, { process });
1860
- }
1861
- catch (e) {
1862
- const msg = e instanceof Error ? e.message : String(e);
1863
- if (msg.includes('not found')) {
1864
- throw new HttpError(404, msg);
1865
- }
1866
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
1867
- throw new HttpError(409, msg);
1868
- }
1869
- throw e;
2260
+ if (msg.includes('not active') || msg.includes('could not be superseded')) {
2261
+ throw new HttpError(409, msg);
1870
2262
  }
1871
- return;
2263
+ throw new HttpError(400, msg);
1872
2264
  }
1873
- const processCloseMatch = path.match(/^\/v1\/processes\/(\d+)\/close$/);
1874
- if (method === 'POST' && processCloseMatch) {
1875
- const id = parseInt(processCloseMatch[1], 10);
1876
- const ctx = await buildContextWithAuth(req, opts);
1877
- try {
1878
- const process = closeProcess(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1879
- sendJson(res, 200, { process });
2265
+ return;
2266
+ }
2267
+ async function handleCloseProjectBrief({ req, res, opts }, briefCloseMatch) {
2268
+ const id = parseInt(briefCloseMatch[1], 10);
2269
+ const ctx = await buildContextWithAuth(req, opts);
2270
+ try {
2271
+ const brief = closeProjectBrief(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2272
+ sendJson(res, 200, { brief });
2273
+ }
2274
+ catch (e) {
2275
+ const msg = e instanceof Error ? e.message : String(e);
2276
+ if (msg.includes('not found')) {
2277
+ throw new HttpError(404, msg);
1880
2278
  }
1881
- catch (e) {
1882
- const msg = e instanceof Error ? e.message : String(e);
1883
- if (msg.includes('not found')) {
1884
- throw new HttpError(404, msg);
1885
- }
1886
- if (msg.includes('not active')) {
1887
- throw new HttpError(409, msg);
1888
- }
1889
- throw e;
2279
+ if (msg.includes('not active')) {
2280
+ throw new HttpError(409, msg);
1890
2281
  }
1891
- return;
2282
+ throw e;
1892
2283
  }
1893
- const processByIdMatch = path.match(/^\/v1\/processes\/(\d+)$/);
1894
- if (method === 'GET' && processByIdMatch) {
1895
- const id = parseInt(processByIdMatch[1], 10);
1896
- const ctx = await buildContextWithAuth(req, opts);
1897
- const process = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
1898
- if (!process) {
1899
- throw new HttpError(404, `process ${id} not found`);
1900
- }
1901
- sendJson(res, 200, { process });
1902
- return;
2284
+ return;
2285
+ }
2286
+ async function handleGetProjectBrief({ req, res, opts }, briefByIdMatch) {
2287
+ const id = parseInt(briefByIdMatch[1], 10);
2288
+ const ctx = await buildContextWithAuth(req, opts);
2289
+ const brief = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
2290
+ if (!brief) {
2291
+ throw new HttpError(404, `project brief ${id} not found`);
2292
+ }
2293
+ sendJson(res, 200, { brief });
2294
+ return;
2295
+ }
2296
+ // ── E2 customer_note routes ──
2297
+ //
2298
+ // 5 routes (no assembler/refresh): POST /v1/customer-notes (new; body customer +
2299
+ // note), GET /v1/customer-notes (list; status + customer filter; shared
2300
+ // parseListLimit), GET /v1/customer-notes/:id, POST /v1/customer-notes/:id/supersede,
2301
+ // POST /v1/customer-notes/:id/close. DoS caps: customer 256, note 8192,
2302
+ // changeSummary 4096. The store validates + throws; the boundary maps validation ->
2303
+ // 400, not-found -> 404, not-active -> 409. Mirrors /v1/project-briefs.
2304
+ async function handleCreateCustomerNote({ req, res, opts }) {
2305
+ const body = await parseJsonBody(req);
2306
+ const customer = body['customer'];
2307
+ if (!isJsonString(customer) || customer.trim().length === 0) {
2308
+ throw new HttpError(400, 'customer is required (non-empty string)');
2309
+ }
2310
+ if (customer.length > 256) {
2311
+ throw new HttpError(400, 'customer exceeds 256-character cap');
2312
+ }
2313
+ const note = body['note'];
2314
+ if (!isJsonString(note) || note.trim().length === 0) {
2315
+ throw new HttpError(400, 'note is required (non-empty string)');
2316
+ }
2317
+ if (note.length > 8192) {
2318
+ throw new HttpError(400, 'note exceeds 8192-character cap');
2319
+ }
2320
+ const ctx = await buildContextWithAuth(req, opts);
2321
+ try {
2322
+ const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2323
+ customer,
2324
+ note,
2325
+ }, ctx.actor.subject);
2326
+ sendJson(res, 201, { note: customerNote });
1903
2327
  }
1904
- // ── policies (E2 first-class object, bi-temporal-first) ──
1905
- //
1906
- // 6 routes: POST /v1/policies (new; processName-style body policyName +
1907
- // policyText + validFrom? + validTo?), GET /v1/policies (list, status filter),
1908
- // GET /v1/policies/asof (date + optional name; the bi-temporal as-of query;
1909
- // placed BEFORE the /:id GET so the literal 'asof' is matched first), GET
1910
- // /v1/policies/:id, POST /v1/policies/:id/supersede, POST /v1/policies/:id/close.
1911
- // Date inputs are normalized + range-validated in the store; an invalid/inverted
1912
- // date throws -> 400. DoS caps: policyName/policyText/changeSummary 4096.
1913
- if (method === 'POST' && path === '/v1/policies') {
1914
- const body = await parseJsonBody(req);
1915
- const policyName = body['policyName'];
1916
- if (!isJsonString(policyName) || policyName.trim().length === 0) {
1917
- throw new HttpError(400, 'policyName is required (non-empty string)');
1918
- }
1919
- if (policyName.length > 4096) {
1920
- throw new HttpError(400, 'policyName exceeds 4096-character cap');
1921
- }
1922
- const policyText = body['policyText'];
1923
- if (!isJsonString(policyText) || policyText.trim().length === 0) {
1924
- throw new HttpError(400, 'policyText is required (non-empty string)');
1925
- }
1926
- if (policyText.length > 4096) {
1927
- throw new HttpError(400, 'policyText exceeds 4096-character cap');
1928
- }
1929
- const validFrom = optionalDateField(body['validFrom'], 'validFrom');
1930
- const validTo = optionalDateField(body['validTo'], 'validTo');
1931
- const ctx = await buildContextWithAuth(req, opts);
1932
- try {
1933
- const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1934
- policyName,
1935
- policyText,
1936
- validFrom,
1937
- validTo,
1938
- }, ctx.actor.subject);
1939
- sendJson(res, 201, { policy });
1940
- }
1941
- catch (e) {
1942
- // savePolicy throws on invalid/inverted dates (validation) -> 400.
1943
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
1944
- }
1945
- return;
2328
+ catch (e) {
2329
+ // saveCustomerNote throws on validation (single-line customer etc.) -> 400.
2330
+ throw new HttpError(400, e instanceof Error ? e.message : String(e));
1946
2331
  }
1947
- if (method === 'GET' && path === '/v1/policies') {
1948
- const status = query.get('status') ?? 'all';
1949
- const limit = parseListLimit(query.get('limit'));
1950
- const ctx = await buildContextWithAuth(req, opts);
1951
- let policies;
1952
- if (status === 'all') {
1953
- policies = loadPolicies(opts.hippoRoot, ctx.tenantId, { limit });
2332
+ return;
2333
+ }
2334
+ async function handleListCustomerNotes({ req, res, opts, query }) {
2335
+ const status = query.get('status') ?? 'all';
2336
+ const customerFilter = query.get('customer');
2337
+ const limit = parseListLimit(query.get('limit'));
2338
+ const ctx = await buildContextWithAuth(req, opts);
2339
+ const listOpts = { limit };
2340
+ if (customerFilter !== null && customerFilter.trim().length > 0) {
2341
+ listOpts.customer = customerFilter.trim();
2342
+ }
2343
+ if (status !== 'all') {
2344
+ if (!isSetMember(VALID_NOTE_STATES, status)) {
2345
+ throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2346
+ }
2347
+ listOpts.status = status;
2348
+ }
2349
+ const notes = loadCustomerNotes(opts.hippoRoot, ctx.tenantId, listOpts);
2350
+ sendJson(res, 200, { notes });
2351
+ return;
2352
+ }
2353
+ async function handleSupersedeCustomerNote({ req, res, opts }, noteSupersedeMatch) {
2354
+ const id = parseInt(noteSupersedeMatch[1], 10);
2355
+ const body = await parseJsonBody(req);
2356
+ const note = body['note'];
2357
+ if (!isJsonString(note) || note.trim().length === 0) {
2358
+ throw new HttpError(400, 'note is required (non-empty string)');
2359
+ }
2360
+ if (note.length > 8192) {
2361
+ throw new HttpError(400, 'note exceeds 8192-character cap');
2362
+ }
2363
+ const changeRaw = body['changeSummary'];
2364
+ let changeSummary;
2365
+ if (changeRaw !== undefined && changeRaw !== null) {
2366
+ if (!isJsonString(changeRaw)) {
2367
+ throw new HttpError(400, 'changeSummary must be a string');
1954
2368
  }
1955
- else {
1956
- if (!isSetMember(VALID_POLICY_STATES, status)) {
1957
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1958
- }
1959
- policies = loadPolicies(opts.hippoRoot, ctx.tenantId, {
1960
- status,
1961
- limit,
1962
- });
2369
+ if (changeRaw.length > 4096) {
2370
+ throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
1963
2371
  }
1964
- sendJson(res, 200, { policies });
1965
- return;
2372
+ changeSummary = changeRaw;
1966
2373
  }
1967
- // The as-of query: must precede the /:id GET (literal 'asof' is non-numeric so
1968
- // the /(\d+)/ route would not match it, but order it first for clarity).
1969
- if (method === 'GET' && path === '/v1/policies/asof') {
1970
- const date = query.get('date');
1971
- if (date === null || date.length === 0) {
1972
- throw new HttpError(400, 'date is required (ISO-8601 valid-time)');
2374
+ const ctx = await buildContextWithAuth(req, opts);
2375
+ const existing = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
2376
+ if (!existing) {
2377
+ throw new HttpError(404, `customer note ${id} not found`);
2378
+ }
2379
+ try {
2380
+ const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2381
+ customer: existing.customer,
2382
+ note,
2383
+ changeSummary,
2384
+ supersedesNoteId: id,
2385
+ }, ctx.actor.subject);
2386
+ sendJson(res, 200, { note: customerNote });
2387
+ }
2388
+ catch (e) {
2389
+ const msg = e instanceof Error ? e.message : String(e);
2390
+ if (msg.includes('not found')) {
2391
+ throw new HttpError(404, msg);
1973
2392
  }
1974
- const name = query.get('name') ?? undefined;
1975
- const ctx = await buildContextWithAuth(req, opts);
1976
- try {
1977
- const policies = loadPoliciesAsOf(opts.hippoRoot, ctx.tenantId, date, { name });
1978
- sendJson(res, 200, { policies });
2393
+ if (msg.includes('not active') || msg.includes('could not be superseded')) {
2394
+ throw new HttpError(409, msg);
1979
2395
  }
1980
- catch (e) {
1981
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
2396
+ throw new HttpError(400, msg);
2397
+ }
2398
+ return;
2399
+ }
2400
+ async function handleCloseCustomerNote({ req, res, opts }, noteCloseMatch) {
2401
+ const id = parseInt(noteCloseMatch[1], 10);
2402
+ const ctx = await buildContextWithAuth(req, opts);
2403
+ try {
2404
+ const customerNote = closeCustomerNote(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2405
+ sendJson(res, 200, { note: customerNote });
2406
+ }
2407
+ catch (e) {
2408
+ const msg = e instanceof Error ? e.message : String(e);
2409
+ if (msg.includes('not found')) {
2410
+ throw new HttpError(404, msg);
1982
2411
  }
1983
- return;
2412
+ if (msg.includes('not active')) {
2413
+ throw new HttpError(409, msg);
2414
+ }
2415
+ throw e;
1984
2416
  }
1985
- const policySupersedeMatch = path.match(/^\/v1\/policies\/(\d+)\/supersede$/);
1986
- if (method === 'POST' && policySupersedeMatch) {
1987
- const id = parseInt(policySupersedeMatch[1], 10);
1988
- const body = await parseJsonBody(req);
1989
- const policyText = body['policyText'];
1990
- if (!isJsonString(policyText) || policyText.trim().length === 0) {
1991
- throw new HttpError(400, 'policyText is required (non-empty string)');
1992
- }
1993
- if (policyText.length > 4096) {
1994
- throw new HttpError(400, 'policyText exceeds 4096-character cap');
1995
- }
1996
- const validFrom = optionalDateField(body['validFrom'], 'validFrom');
1997
- const validTo = optionalDateField(body['validTo'], 'validTo');
1998
- const changeRaw = body['changeSummary'];
1999
- let changeSummary;
2000
- if (changeRaw !== undefined && changeRaw !== null) {
2001
- if (!isJsonString(changeRaw)) {
2002
- throw new HttpError(400, 'changeSummary must be a string');
2003
- }
2004
- if (changeRaw.length > 4096) {
2005
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
2417
+ return;
2418
+ }
2419
+ async function handleGetCustomerNote({ req, res, opts }, noteByIdMatch) {
2420
+ const id = parseInt(noteByIdMatch[1], 10);
2421
+ const ctx = await buildContextWithAuth(req, opts);
2422
+ const customerNote = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
2423
+ if (!customerNote) {
2424
+ throw new HttpError(404, `customer note ${id} not found`);
2425
+ }
2426
+ sendJson(res, 200, { note: customerNote });
2427
+ return;
2428
+ }
2429
+ /** The /v1 routes in dispatch order; the first entry whose method and path match handles the request. */
2430
+ const V1_ROUTES = [
2431
+ { method: 'POST', path: '/v1/memories', handler: handleCreateMemory },
2432
+ { method: 'GET', path: '/v1/graph', handler: handleGetGraph },
2433
+ { method: 'GET', path: '/v1/memories', handler: handleRecallMemories },
2434
+ { method: 'GET', pattern: '/v1/sessions/:id/assemble', handler: handleAssembleSession },
2435
+ { method: 'GET', pattern: '/v1/recall/drill/:id', handler: handleDrillRecall },
2436
+ { method: 'POST', pattern: '/v1/memories/:id/archive', handler: handleArchiveMemory },
2437
+ { method: 'POST', pattern: '/v1/memories/:id/supersede', handler: handleSupersedeMemory },
2438
+ { method: 'POST', pattern: '/v1/memories/:id/promote', handler: handlePromoteMemory },
2439
+ { method: 'DELETE', pattern: '/v1/memories/:id', handler: handleForgetMemory },
2440
+ { method: 'POST', path: '/v1/outcome', handler: handleApplyOutcome },
2441
+ { method: 'GET', path: '/v1/context', handler: handleGetContext },
2442
+ { method: 'POST', path: '/v1/sleep', handler: handleSleep },
2443
+ { method: 'POST', path: '/v1/auth/keys', handler: handleCreateAuthKey },
2444
+ { method: 'GET', path: '/v1/auth/keys', handler: handleListAuthKeys },
2445
+ { method: 'DELETE', pattern: '/v1/auth/keys/:keyId', handler: handleRevokeAuthKey },
2446
+ { method: 'GET', path: '/v1/quarantine', handler: handleListQuarantine },
2447
+ { method: 'POST', pattern: '/v1/quarantine/:id/approve', handler: handleApproveQuarantine },
2448
+ { method: 'POST', pattern: '/v1/quarantine/:id/reject', handler: handleRejectQuarantine },
2449
+ { method: 'GET', path: '/v1/audit', handler: handleListAudit },
2450
+ { method: 'POST', path: '/v1/predictions', handler: handleCreatePrediction },
2451
+ { method: 'GET', path: '/v1/predictions', handler: handleListPredictions },
2452
+ { method: 'GET', path: '/v1/predictions/stats', handler: handlePredictionStats },
2453
+ { method: 'GET', regex: /^\/v1\/predictions\/(\d+)$/, handler: handleGetPrediction },
2454
+ { method: 'POST', regex: /^\/v1\/predictions\/(\d+)\/close$/, handler: handleClosePrediction },
2455
+ { method: 'POST', path: '/v1/decisions', handler: handleCreateDecision },
2456
+ { method: 'GET', path: '/v1/decisions', handler: handleListDecisions },
2457
+ { method: 'POST', regex: /^\/v1\/decisions\/(\d+)\/supersede$/, handler: handleSupersedeDecision },
2458
+ { method: 'POST', regex: /^\/v1\/decisions\/(\d+)\/close$/, handler: handleCloseDecision },
2459
+ { method: 'GET', regex: /^\/v1\/decisions\/(\d+)$/, handler: handleGetDecision },
2460
+ { method: 'POST', path: '/v1/incidents', handler: handleCreateIncident },
2461
+ { method: 'GET', path: '/v1/incidents', handler: handleListIncidents },
2462
+ { method: 'POST', regex: /^\/v1\/incidents\/(\d+)\/resolve$/, handler: handleResolveIncident },
2463
+ { method: 'POST', regex: /^\/v1\/incidents\/(\d+)\/close$/, handler: handleCloseIncident },
2464
+ { method: 'GET', regex: /^\/v1\/incidents\/(\d+)$/, handler: handleGetIncident },
2465
+ { method: 'POST', path: '/v1/processes', handler: handleCreateProcess },
2466
+ { method: 'GET', path: '/v1/processes', handler: handleListProcesses },
2467
+ { method: 'POST', regex: /^\/v1\/processes\/(\d+)\/supersede$/, handler: handleSupersedeProcess },
2468
+ { method: 'POST', regex: /^\/v1\/processes\/(\d+)\/close$/, handler: handleCloseProcess },
2469
+ { method: 'GET', regex: /^\/v1\/processes\/(\d+)$/, handler: handleGetProcess },
2470
+ { method: 'POST', path: '/v1/policies', handler: handleCreatePolicy },
2471
+ { method: 'GET', path: '/v1/policies', handler: handleListPolicies },
2472
+ { method: 'GET', path: '/v1/policies/asof', handler: handlePoliciesAsOf },
2473
+ { method: 'POST', regex: /^\/v1\/policies\/(\d+)\/supersede$/, handler: handleSupersedePolicy },
2474
+ { method: 'POST', regex: /^\/v1\/policies\/(\d+)\/close$/, handler: handleClosePolicy },
2475
+ { method: 'GET', regex: /^\/v1\/policies\/(\d+)$/, handler: handleGetPolicy },
2476
+ { method: 'POST', path: '/v1/skills', handler: handleCreateSkill },
2477
+ { method: 'GET', path: '/v1/skills', handler: handleListSkills },
2478
+ { method: 'GET', path: '/v1/skills/export', handler: handleExportSkills },
2479
+ { method: 'POST', regex: /^\/v1\/skills\/(\d+)\/supersede$/, handler: handleSupersedeSkill },
2480
+ { method: 'POST', regex: /^\/v1\/skills\/(\d+)\/close$/, handler: handleCloseSkill },
2481
+ { method: 'GET', regex: /^\/v1\/skills\/(\d+)$/, handler: handleGetSkill },
2482
+ { method: 'POST', path: '/v1/project-briefs', handler: handleCreateProjectBrief },
2483
+ { method: 'GET', path: '/v1/project-briefs', handler: handleListProjectBriefs },
2484
+ { method: 'POST', path: '/v1/project-briefs/refresh', handler: handleRefreshProjectBrief },
2485
+ { method: 'POST', regex: /^\/v1\/project-briefs\/(\d+)\/supersede$/, handler: handleSupersedeProjectBrief },
2486
+ { method: 'POST', regex: /^\/v1\/project-briefs\/(\d+)\/close$/, handler: handleCloseProjectBrief },
2487
+ { method: 'GET', regex: /^\/v1\/project-briefs\/(\d+)$/, handler: handleGetProjectBrief },
2488
+ { method: 'POST', path: '/v1/customer-notes', handler: handleCreateCustomerNote },
2489
+ { method: 'GET', path: '/v1/customer-notes', handler: handleListCustomerNotes },
2490
+ { method: 'POST', regex: /^\/v1\/customer-notes\/(\d+)\/supersede$/, handler: handleSupersedeCustomerNote },
2491
+ { method: 'POST', regex: /^\/v1\/customer-notes\/(\d+)\/close$/, handler: handleCloseCustomerNote },
2492
+ { method: 'GET', regex: /^\/v1\/customer-notes\/(\d+)$/, handler: handleGetCustomerNote },
2493
+ ];
2494
+ /**
2495
+ * Run the first /v1 route whose method and path match. Each matcher runs before its method check, as the
2496
+ * inline route blocks did, so a malformed `%` escape still throws from matchPath on any method.
2497
+ */
2498
+ async function dispatchV1Route(r, method, path) {
2499
+ for (const route of V1_ROUTES) {
2500
+ if ('path' in route) {
2501
+ if (method === route.method && path === route.path) {
2502
+ await route.handler(r);
2503
+ return true;
2006
2504
  }
2007
- changeSummary = changeRaw;
2008
2505
  }
2009
- const ctx = await buildContextWithAuth(req, opts);
2010
- const existing = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
2011
- if (!existing) {
2012
- throw new HttpError(404, `policy ${id} not found`);
2013
- }
2014
- try {
2015
- const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
2016
- policyName: existing.policyName,
2017
- policyText,
2018
- validFrom,
2019
- validTo,
2020
- changeSummary,
2021
- supersedesPolicyId: id,
2022
- }, ctx.actor.subject);
2023
- sendJson(res, 200, { policy });
2024
- }
2025
- catch (e) {
2026
- const msg = e instanceof Error ? e.message : String(e);
2027
- if (msg.includes('not found')) {
2028
- throw new HttpError(404, msg);
2029
- }
2030
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2031
- throw new HttpError(409, msg);
2032
- }
2033
- // invalid/inverted date or missing field -> validation.
2034
- throw new HttpError(400, msg);
2035
- }
2036
- return;
2037
- }
2038
- const policyCloseMatch = path.match(/^\/v1\/policies\/(\d+)\/close$/);
2039
- if (method === 'POST' && policyCloseMatch) {
2040
- const id = parseInt(policyCloseMatch[1], 10);
2041
- const ctx = await buildContextWithAuth(req, opts);
2042
- try {
2043
- const policy = closePolicy(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2044
- sendJson(res, 200, { policy });
2045
- }
2046
- catch (e) {
2047
- const msg = e instanceof Error ? e.message : String(e);
2048
- if (msg.includes('not found')) {
2049
- throw new HttpError(404, msg);
2050
- }
2051
- if (msg.includes('not active')) {
2052
- throw new HttpError(409, msg);
2053
- }
2054
- throw e;
2055
- }
2056
- return;
2057
- }
2058
- const policyByIdMatch = path.match(/^\/v1\/policies\/(\d+)$/);
2059
- if (method === 'GET' && policyByIdMatch) {
2060
- const id = parseInt(policyByIdMatch[1], 10);
2061
- const ctx = await buildContextWithAuth(req, opts);
2062
- const policy = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
2063
- if (!policy) {
2064
- throw new HttpError(404, `policy ${id} not found`);
2065
- }
2066
- sendJson(res, 200, { policy });
2067
- return;
2068
- }
2069
- // ── skills (E2 first-class object, executable/exportable) ──
2070
- //
2071
- // 6 routes: POST /v1/skills (new; body skillName + instructions + trigger?),
2072
- // GET /v1/skills (list, status filter; shared parseListLimit), GET
2073
- // /v1/skills/export (renders ACTIVE skills as an AGENTS.md/CLAUDE.md markdown
2074
- // block -> {markdown}; literal 'export' is non-numeric so the /:id (\d+) route
2075
- // cannot capture it, but it is ordered first regardless), GET /v1/skills/:id,
2076
- // POST /v1/skills/:id/supersede, POST /v1/skills/:id/close. DoS caps:
2077
- // skillName 256, instructions 8192, trigger 1024, changeSummary 4096. The store
2078
- // validates + throws; the boundary maps validation -> 400, not-found -> 404,
2079
- // not-active -> 409. Mirrors /v1/processes; "executable" = exportable
2080
- // instruction (no code exec).
2081
- if (method === 'POST' && path === '/v1/skills') {
2082
- const body = await parseJsonBody(req);
2083
- const skillName = body['skillName'];
2084
- if (!isJsonString(skillName) || skillName.trim().length === 0) {
2085
- throw new HttpError(400, 'skillName is required (non-empty string)');
2086
- }
2087
- if (skillName.length > 256) {
2088
- throw new HttpError(400, 'skillName exceeds 256-character cap');
2089
- }
2090
- const instructions = body['instructions'];
2091
- if (!isJsonString(instructions) || instructions.trim().length === 0) {
2092
- throw new HttpError(400, 'instructions are required (non-empty string)');
2093
- }
2094
- if (instructions.length > 8192) {
2095
- throw new HttpError(400, 'instructions exceed 8192-character cap');
2096
- }
2097
- const triggerRaw = body['trigger'];
2098
- let trigger;
2099
- if (triggerRaw !== undefined && triggerRaw !== null) {
2100
- if (!isJsonString(triggerRaw)) {
2101
- throw new HttpError(400, 'trigger must be a string');
2102
- }
2103
- if (triggerRaw.length > 1024) {
2104
- throw new HttpError(400, 'trigger exceeds 1024-character cap');
2105
- }
2106
- trigger = triggerRaw;
2107
- }
2108
- const ctx = await buildContextWithAuth(req, opts);
2109
- try {
2110
- const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
2111
- skillName,
2112
- instructions,
2113
- trigger,
2114
- }, ctx.actor.subject);
2115
- sendJson(res, 201, { skill });
2116
- }
2117
- catch (e) {
2118
- // saveSkill throws on validation (single-line name etc.) -> 400.
2119
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
2120
- }
2121
- return;
2122
- }
2123
- if (method === 'GET' && path === '/v1/skills') {
2124
- const status = query.get('status') ?? 'all';
2125
- const limit = parseListLimit(query.get('limit'));
2126
- const ctx = await buildContextWithAuth(req, opts);
2127
- let skills;
2128
- if (status === 'all') {
2129
- skills = loadSkills(opts.hippoRoot, ctx.tenantId, { limit });
2506
+ else if ('pattern' in route) {
2507
+ const params = matchPath(route.pattern, path);
2508
+ if (method === route.method && params) {
2509
+ await route.handler(r, params);
2510
+ return true;
2511
+ }
2130
2512
  }
2131
2513
  else {
2132
- if (!isSetMember(VALID_SKILL_STATES, status)) {
2133
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2134
- }
2135
- skills = loadSkills(opts.hippoRoot, ctx.tenantId, {
2136
- status,
2137
- limit,
2138
- });
2139
- }
2140
- sendJson(res, 200, { skills });
2141
- return;
2142
- }
2143
- // The export renderer: must precede the /:id GET (literal 'export' is
2144
- // non-numeric so the /(\d+)/ route would not match it, but order it first).
2145
- if (method === 'GET' && path === '/v1/skills/export') {
2146
- const ctx = await buildContextWithAuth(req, opts);
2147
- const markdown = exportSkills(opts.hippoRoot, ctx.tenantId);
2148
- sendJson(res, 200, { markdown });
2149
- return;
2150
- }
2151
- const skillSupersedeMatch = path.match(/^\/v1\/skills\/(\d+)\/supersede$/);
2152
- if (method === 'POST' && skillSupersedeMatch) {
2153
- const id = parseInt(skillSupersedeMatch[1], 10);
2154
- const body = await parseJsonBody(req);
2155
- const instructions = body['instructions'];
2156
- if (!isJsonString(instructions) || instructions.trim().length === 0) {
2157
- throw new HttpError(400, 'instructions are required (non-empty string)');
2158
- }
2159
- if (instructions.length > 8192) {
2160
- throw new HttpError(400, 'instructions exceed 8192-character cap');
2161
- }
2162
- const triggerRaw = body['trigger'];
2163
- let trigger;
2164
- if (triggerRaw !== undefined && triggerRaw !== null) {
2165
- if (!isJsonString(triggerRaw)) {
2166
- throw new HttpError(400, 'trigger must be a string');
2167
- }
2168
- if (triggerRaw.length > 1024) {
2169
- throw new HttpError(400, 'trigger exceeds 1024-character cap');
2170
- }
2171
- trigger = triggerRaw;
2172
- }
2173
- const changeRaw = body['changeSummary'];
2174
- let changeSummary;
2175
- if (changeRaw !== undefined && changeRaw !== null) {
2176
- if (!isJsonString(changeRaw)) {
2177
- throw new HttpError(400, 'changeSummary must be a string');
2178
- }
2179
- if (changeRaw.length > 4096) {
2180
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
2181
- }
2182
- changeSummary = changeRaw;
2183
- }
2184
- const ctx = await buildContextWithAuth(req, opts);
2185
- const existing = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
2186
- if (!existing) {
2187
- throw new HttpError(404, `skill ${id} not found`);
2188
- }
2189
- try {
2190
- const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
2191
- skillName: existing.skillName,
2192
- instructions,
2193
- trigger,
2194
- changeSummary,
2195
- supersedesSkillId: id,
2196
- }, ctx.actor.subject);
2197
- sendJson(res, 200, { skill });
2198
- }
2199
- catch (e) {
2200
- const msg = e instanceof Error ? e.message : String(e);
2201
- if (msg.includes('not found')) {
2202
- throw new HttpError(404, msg);
2203
- }
2204
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2205
- throw new HttpError(409, msg);
2206
- }
2207
- throw new HttpError(400, msg);
2208
- }
2209
- return;
2210
- }
2211
- const skillCloseMatch = path.match(/^\/v1\/skills\/(\d+)\/close$/);
2212
- if (method === 'POST' && skillCloseMatch) {
2213
- const id = parseInt(skillCloseMatch[1], 10);
2214
- const ctx = await buildContextWithAuth(req, opts);
2215
- try {
2216
- const skill = closeSkill(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2217
- sendJson(res, 200, { skill });
2218
- }
2219
- catch (e) {
2220
- const msg = e instanceof Error ? e.message : String(e);
2221
- if (msg.includes('not found')) {
2222
- throw new HttpError(404, msg);
2223
- }
2224
- if (msg.includes('not active')) {
2225
- throw new HttpError(409, msg);
2226
- }
2227
- throw e;
2228
- }
2229
- return;
2230
- }
2231
- const skillByIdMatch = path.match(/^\/v1\/skills\/(\d+)$/);
2232
- if (method === 'GET' && skillByIdMatch) {
2233
- const id = parseInt(skillByIdMatch[1], 10);
2234
- const ctx = await buildContextWithAuth(req, opts);
2235
- const skill = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
2236
- if (!skill) {
2237
- throw new HttpError(404, `skill ${id} not found`);
2238
- }
2239
- sendJson(res, 200, { skill });
2240
- return;
2241
- }
2242
- // ── E2 project_brief routes ──
2243
- //
2244
- // 6 routes: POST /v1/project-briefs (new; body repo + summary), GET
2245
- // /v1/project-briefs (list; status + repo filter; shared parseListLimit), POST
2246
- // /v1/project-briefs/refresh (body {repo, dryRun?} -> auto-assemble the brief
2247
- // from the repo's receipts; dryRun returns {markdown} without writing; ordered
2248
- // before /:id), GET /v1/project-briefs/:id, POST /v1/project-briefs/:id/supersede,
2249
- // POST /v1/project-briefs/:id/close. DoS caps: repo 256, summary 8192,
2250
- // changeSummary 4096. The store validates + throws; the boundary maps validation
2251
- // -> 400, not-found -> 404, not-active -> 409. Mirrors /v1/skills.
2252
- if (method === 'POST' && path === '/v1/project-briefs') {
2253
- const body = await parseJsonBody(req);
2254
- const repo = body['repo'];
2255
- if (!isJsonString(repo) || repo.trim().length === 0) {
2256
- throw new HttpError(400, 'repo is required (non-empty string)');
2257
- }
2258
- if (repo.length > 256) {
2259
- throw new HttpError(400, 'repo exceeds 256-character cap');
2260
- }
2261
- const summary = body['summary'];
2262
- if (!isJsonString(summary) || summary.trim().length === 0) {
2263
- throw new HttpError(400, 'summary is required (non-empty string)');
2264
- }
2265
- if (summary.length > 8192) {
2266
- throw new HttpError(400, 'summary exceeds 8192-character cap');
2267
- }
2268
- const ctx = await buildContextWithAuth(req, opts);
2269
- try {
2270
- const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2271
- repo,
2272
- summary,
2273
- }, ctx.actor.subject);
2274
- sendJson(res, 201, { brief });
2275
- }
2276
- catch (e) {
2277
- // saveProjectBrief throws on validation (single-line repo etc.) -> 400.
2278
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
2279
- }
2280
- return;
2281
- }
2282
- if (method === 'GET' && path === '/v1/project-briefs') {
2283
- const status = query.get('status') ?? 'all';
2284
- const repoFilter = query.get('repo');
2285
- const limit = parseListLimit(query.get('limit'));
2286
- const ctx = await buildContextWithAuth(req, opts);
2287
- const listOpts = { limit };
2288
- if (repoFilter !== null && repoFilter.trim().length > 0) {
2289
- listOpts.repo = repoFilter.trim();
2290
- }
2291
- if (status !== 'all') {
2292
- if (!isSetMember(VALID_BRIEF_STATES, status)) {
2293
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2294
- }
2295
- listOpts.status = status;
2296
- }
2297
- const briefs = loadProjectBriefs(opts.hippoRoot, ctx.tenantId, listOpts);
2298
- sendJson(res, 200, { briefs });
2299
- return;
2300
- }
2301
- // The refresh op: must precede the /:id routes (literal 'refresh' is non-numeric
2302
- // so the /(\d+)/ routes would not match it, but order it first).
2303
- if (method === 'POST' && path === '/v1/project-briefs/refresh') {
2304
- const body = await parseJsonBody(req);
2305
- const repo = body['repo'];
2306
- if (!isJsonString(repo) || repo.trim().length === 0) {
2307
- throw new HttpError(400, 'repo is required (non-empty string)');
2308
- }
2309
- if (repo.length > 256) {
2310
- throw new HttpError(400, 'repo exceeds 256-character cap');
2311
- }
2312
- const dryRun = body['dryRun'] === true;
2313
- const ctx = await buildContextWithAuth(req, opts);
2314
- try {
2315
- if (dryRun) {
2316
- const { markdown, receiptCount } = assembleBriefFromReceipts(opts.hippoRoot, ctx.tenantId, repo);
2317
- sendJson(res, 200, { markdown, receiptCount });
2318
- return;
2514
+ const match = path.match(route.regex);
2515
+ if (method === route.method && match) {
2516
+ await route.handler(r, match);
2517
+ return true;
2319
2518
  }
2320
- const brief = refreshBrief(opts.hippoRoot, ctx.tenantId, repo, ctx.actor.subject);
2321
- sendJson(res, 200, { brief });
2322
- }
2323
- catch (e) {
2324
- // A refresh race (the active brief is closed/superseded between
2325
- // loadActiveBriefForRepo and the supersede CAS) is a state conflict, not a
2326
- // validation error — map it to 409 like the explicit supersede route
2327
- // (codex-review 2026-05-30, P3).
2328
- const msg = e instanceof Error ? e.message : String(e);
2329
- if (msg.includes('not found')) {
2330
- throw new HttpError(404, msg);
2331
- }
2332
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2333
- throw new HttpError(409, msg);
2334
- }
2335
- throw new HttpError(400, msg);
2336
2519
  }
2337
- return;
2338
2520
  }
2339
- const briefSupersedeMatch = path.match(/^\/v1\/project-briefs\/(\d+)\/supersede$/);
2340
- if (method === 'POST' && briefSupersedeMatch) {
2341
- const id = parseInt(briefSupersedeMatch[1], 10);
2342
- const body = await parseJsonBody(req);
2343
- const summary = body['summary'];
2344
- if (!isJsonString(summary) || summary.trim().length === 0) {
2345
- throw new HttpError(400, 'summary is required (non-empty string)');
2346
- }
2347
- if (summary.length > 8192) {
2348
- throw new HttpError(400, 'summary exceeds 8192-character cap');
2349
- }
2350
- const changeRaw = body['changeSummary'];
2351
- let changeSummary;
2352
- if (changeRaw !== undefined && changeRaw !== null) {
2353
- if (!isJsonString(changeRaw)) {
2354
- throw new HttpError(400, 'changeSummary must be a string');
2355
- }
2356
- if (changeRaw.length > 4096) {
2357
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
2358
- }
2359
- changeSummary = changeRaw;
2360
- }
2361
- const ctx = await buildContextWithAuth(req, opts);
2362
- const existing = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
2363
- if (!existing) {
2364
- throw new HttpError(404, `project brief ${id} not found`);
2365
- }
2366
- try {
2367
- const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2368
- repo: existing.repo,
2369
- summary,
2370
- changeSummary,
2371
- supersedesBriefId: id,
2372
- }, ctx.actor.subject);
2373
- sendJson(res, 200, { brief });
2374
- }
2375
- catch (e) {
2376
- const msg = e instanceof Error ? e.message : String(e);
2377
- if (msg.includes('not found')) {
2378
- throw new HttpError(404, msg);
2379
- }
2380
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2381
- throw new HttpError(409, msg);
2382
- }
2383
- throw new HttpError(400, msg);
2384
- }
2385
- return;
2386
- }
2387
- const briefCloseMatch = path.match(/^\/v1\/project-briefs\/(\d+)\/close$/);
2388
- if (method === 'POST' && briefCloseMatch) {
2389
- const id = parseInt(briefCloseMatch[1], 10);
2390
- const ctx = await buildContextWithAuth(req, opts);
2391
- try {
2392
- const brief = closeProjectBrief(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2393
- sendJson(res, 200, { brief });
2394
- }
2395
- catch (e) {
2396
- const msg = e instanceof Error ? e.message : String(e);
2397
- if (msg.includes('not found')) {
2398
- throw new HttpError(404, msg);
2399
- }
2400
- if (msg.includes('not active')) {
2401
- throw new HttpError(409, msg);
2402
- }
2403
- throw e;
2521
+ return false;
2522
+ }
2523
+ async function handleRequest(req, res, opts, startedAt, limiter) {
2524
+ // v1.6.4: pre-decode raw-URL slash check. Catches `%2F` / `%2f` before
2525
+ // Node's URL parser collapses them and they slip past the route table.
2526
+ rejectEncodedSlash(req.url ?? '/');
2527
+ const { method, path, query } = parseRequest(req);
2528
+ if (method === 'GET' && path === '/health') {
2529
+ // Loopback callers (detectServer's stale-pidfile probe reads version and
2530
+ // pid) get the full body. Non-loopback callers get liveness only: the
2531
+ // version string would fingerprint the build for the public internet and
2532
+ // the pid is noise. Platform health checks only need the 200.
2533
+ if (isLoopback(req.socket.remoteAddress)) {
2534
+ sendJson(res, 200, {
2535
+ ok: true,
2536
+ version: VERSION,
2537
+ started_at: startedAt,
2538
+ pid: process.pid,
2539
+ });
2404
2540
  }
2405
- return;
2406
- }
2407
- const briefByIdMatch = path.match(/^\/v1\/project-briefs\/(\d+)$/);
2408
- if (method === 'GET' && briefByIdMatch) {
2409
- const id = parseInt(briefByIdMatch[1], 10);
2410
- const ctx = await buildContextWithAuth(req, opts);
2411
- const brief = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
2412
- if (!brief) {
2413
- throw new HttpError(404, `project brief ${id} not found`);
2541
+ else {
2542
+ sendJson(res, 200, { ok: true });
2414
2543
  }
2415
- sendJson(res, 200, { brief });
2416
2544
  return;
2417
2545
  }
2418
- // ── E2 customer_note routes ──
2546
+ // E3: per-IP rate limit on /v1/* and /mcp* to bound api-key-id enumeration. /health
2547
+ // (a liveness probe) and other paths are never throttled. A 429 thrown
2548
+ // here lands in the createServer catch like any other HttpError.
2419
2549
  //
2420
- // 5 routes (no assembler/refresh): POST /v1/customer-notes (new; body customer +
2421
- // note), GET /v1/customer-notes (list; status + customer filter; shared
2422
- // parseListLimit), GET /v1/customer-notes/:id, POST /v1/customer-notes/:id/supersede,
2423
- // POST /v1/customer-notes/:id/close. DoS caps: customer 256, note 8192,
2424
- // changeSummary 4096. The store validates + throws; the boundary maps validation ->
2425
- // 400, not-found -> 404, not-active -> 409. Mirrors /v1/project-briefs.
2426
- if (method === 'POST' && path === '/v1/customer-notes') {
2427
- const body = await parseJsonBody(req);
2428
- const customer = body['customer'];
2429
- if (!isJsonString(customer) || customer.trim().length === 0) {
2430
- throw new HttpError(400, 'customer is required (non-empty string)');
2431
- }
2432
- if (customer.length > 256) {
2433
- throw new HttpError(400, 'customer exceeds 256-character cap');
2434
- }
2435
- const note = body['note'];
2436
- if (!isJsonString(note) || note.trim().length === 0) {
2437
- throw new HttpError(400, 'note is required (non-empty string)');
2438
- }
2439
- if (note.length > 8192) {
2440
- throw new HttpError(400, 'note exceeds 8192-character cap');
2441
- }
2442
- const ctx = await buildContextWithAuth(req, opts);
2443
- try {
2444
- const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2445
- customer,
2446
- note,
2447
- }, ctx.actor.subject);
2448
- sendJson(res, 201, { note: customerNote });
2449
- }
2450
- catch (e) {
2451
- // saveCustomerNote throws on validation (single-line customer etc.) -> 400.
2452
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
2453
- }
2454
- return;
2455
- }
2456
- if (method === 'GET' && path === '/v1/customer-notes') {
2457
- const status = query.get('status') ?? 'all';
2458
- const customerFilter = query.get('customer');
2459
- const limit = parseListLimit(query.get('limit'));
2460
- const ctx = await buildContextWithAuth(req, opts);
2461
- const listOpts = { limit };
2462
- if (customerFilter !== null && customerFilter.trim().length > 0) {
2463
- listOpts.customer = customerFilter.trim();
2464
- }
2465
- if (status !== 'all') {
2466
- if (!isSetMember(VALID_NOTE_STATES, status)) {
2467
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2468
- }
2469
- listOpts.status = status;
2470
- }
2471
- const notes = loadCustomerNotes(opts.hippoRoot, ctx.tenantId, listOpts);
2472
- sendJson(res, 200, { notes });
2473
- return;
2474
- }
2475
- const noteSupersedeMatch = path.match(/^\/v1\/customer-notes\/(\d+)\/supersede$/);
2476
- if (method === 'POST' && noteSupersedeMatch) {
2477
- const id = parseInt(noteSupersedeMatch[1], 10);
2478
- const body = await parseJsonBody(req);
2479
- const note = body['note'];
2480
- if (!isJsonString(note) || note.trim().length === 0) {
2481
- throw new HttpError(400, 'note is required (non-empty string)');
2482
- }
2483
- if (note.length > 8192) {
2484
- throw new HttpError(400, 'note exceeds 8192-character cap');
2485
- }
2486
- const changeRaw = body['changeSummary'];
2487
- let changeSummary;
2488
- if (changeRaw !== undefined && changeRaw !== null) {
2489
- if (!isJsonString(changeRaw)) {
2490
- throw new HttpError(400, 'changeSummary must be a string');
2491
- }
2492
- if (changeRaw.length > 4096) {
2493
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
2494
- }
2495
- changeSummary = changeRaw;
2496
- }
2497
- const ctx = await buildContextWithAuth(req, opts);
2498
- const existing = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
2499
- if (!existing) {
2500
- throw new HttpError(404, `customer note ${id} not found`);
2501
- }
2502
- try {
2503
- const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2504
- customer: existing.customer,
2505
- note,
2506
- changeSummary,
2507
- supersedesNoteId: id,
2508
- }, ctx.actor.subject);
2509
- sendJson(res, 200, { note: customerNote });
2510
- }
2511
- catch (e) {
2512
- const msg = e instanceof Error ? e.message : String(e);
2513
- if (msg.includes('not found')) {
2514
- throw new HttpError(404, msg);
2515
- }
2516
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2517
- throw new HttpError(409, msg);
2518
- }
2519
- throw new HttpError(400, msg);
2550
+ // Keyed on the socket's remote address by default. Behind a TLS-terminating
2551
+ // proxy every socket carries the proxy's address, collapsing the per-IP
2552
+ // buckets into one global bucket that pre-auth traffic can drain; set
2553
+ // HIPPO_CLIENT_IP_HEADER there so each real client gets its own bucket
2554
+ // (see clientIpForRateLimit).
2555
+ if (limiter && (path.startsWith('/v1/') || path === '/mcp' || path === '/mcp/stream')) {
2556
+ const ip = clientIpForRateLimit(req);
2557
+ if (!limiter.check(ip)) {
2558
+ throw new HttpError(429, 'rate limit exceeded');
2520
2559
  }
2521
- return;
2522
2560
  }
2523
- const noteCloseMatch = path.match(/^\/v1\/customer-notes\/(\d+)\/close$/);
2524
- if (method === 'POST' && noteCloseMatch) {
2525
- const id = parseInt(noteCloseMatch[1], 10);
2526
- const ctx = await buildContextWithAuth(req, opts);
2527
- try {
2528
- const customerNote = closeCustomerNote(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2529
- sendJson(res, 200, { note: customerNote });
2530
- }
2531
- catch (e) {
2532
- const msg = e instanceof Error ? e.message : String(e);
2533
- if (msg.includes('not found')) {
2534
- throw new HttpError(404, msg);
2535
- }
2536
- if (msg.includes('not active')) {
2537
- throw new HttpError(409, msg);
2538
- }
2539
- throw e;
2540
- }
2561
+ if (await dispatchV1Route({ req, res, opts, query }, method, path))
2541
2562
  return;
2542
- }
2543
- const noteByIdMatch = path.match(/^\/v1\/customer-notes\/(\d+)$/);
2544
- if (method === 'GET' && noteByIdMatch) {
2545
- const id = parseInt(noteByIdMatch[1], 10);
2546
- const ctx = await buildContextWithAuth(req, opts);
2547
- const customerNote = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
2548
- if (!customerNote) {
2549
- throw new HttpError(404, `customer note ${id} not found`);
2550
- }
2551
- sendJson(res, 200, { note: customerNote });
2552
- return;
2553
- }
2554
- // ── POST /v1/connectors/slack/events ──
2555
- //
2556
- // Slack Events API webhook. Auth is signature-based (HMAC over the raw
2557
- // body with SLACK_SIGNING_SECRET); Bearer is NOT required, which is why
2558
- // this route is in PUBLIC_ROUTES. The route is responsible for:
2559
- // 1. Echoing the one-time url_verification challenge.
2560
- // 2. Verifying the HMAC on every other inbound payload.
2561
- // 3. Resolving body.team_id → tenantId via slack_workspaces, falling
2562
- // back to HIPPO_TENANT then 'default'.
2563
- // 4. Dispatching event_callback envelopes to ingestMessage /
2564
- // handleMessageDeleted.
2565
- // 5. Parking malformed or unhandled payloads in slack_dlq and STILL
2566
- // ACKing 200 — Slack retries forever otherwise.
2567
- //
2568
- // Review patch #7: when SLACK_SIGNING_SECRET is unset we return 404, not
2569
- // 503, so an external probe cannot distinguish "route gated off by config"
2570
- // from "route does not exist on this build".
2571
2563
  if (method === 'POST' && path === '/v1/connectors/slack/events') {
2572
- // Bearer auth deliberately skipped — this route is in PUBLIC_ROUTES
2573
- // and authenticates via the Slack HMAC signature instead.
2564
+ // Bearer auth deliberately skipped: this route is in PUBLIC_ROUTES and authenticates via the Slack HMAC signature.
2574
2565
  if (!isPublicRoute(method, path)) {
2575
2566
  // Defensive: PUBLIC_ROUTES drift would land here. Fail closed.
2576
2567
  throw new HttpError(401, 'auth required');
2577
2568
  }
2578
- const rawBody = await readBody(req);
2579
- const secret = process.env.SLACK_SIGNING_SECRET;
2580
- if (!secret) {
2581
- res.writeHead(404, JSON_HEADERS);
2582
- res.end(JSON.stringify({ error: 'not found' }));
2583
- return;
2584
- }
2585
- const previousSecret = process.env.SLACK_SIGNING_SECRET_PREVIOUS;
2586
- const sig = req.headers['x-slack-signature'];
2587
- const tsHdr = req.headers['x-slack-request-timestamp'];
2588
- const sigStr = isHeaderString(sig) ? sig : null;
2589
- const tsStr = isHeaderString(tsHdr) ? tsHdr : null;
2590
- if (sigStr === null ||
2591
- tsStr === null ||
2592
- !verifySlackSignature({
2593
- rawBody,
2594
- timestamp: tsStr,
2595
- signature: sigStr,
2596
- signingSecret: secret,
2597
- previousSecret,
2598
- })) {
2599
- throw new HttpError(401, 'invalid Slack signature');
2600
- }
2601
- // Cheap regex extracts team_id from a (possibly malformed) raw body so the
2602
- // DLQ row carries it for triage even when JSON.parse fails.
2603
- const teamIdFromRaw = (() => {
2604
- const m = rawBody.match(/"team_id"\s*:\s*"([^"]+)"/);
2605
- return m ? m[1] : null;
2606
- })();
2607
- let body;
2608
- try {
2609
- body = JSON.parse(rawBody);
2610
- }
2611
- catch {
2612
- // v1.12.6 (B4): parse-failure tenant attribution. Pre-fix this path
2613
- // wrote tenant_id=HIPPO_TENANT regardless of the originating workspace,
2614
- // silently routing parse failures from workspace A into the deployment's
2615
- // tenant DLQ. Fix: use the regex-extracted teamIdFromRaw to resolve
2616
- // tenant via the same slack_workspaces table the happy path uses
2617
- // (resolveTenantForTeam at line ~1044). When teamIdFromRaw is null
2618
- // (totally unparseable body) OR the team is unknown, write with
2619
- // tenantId=null so the row lands as '__unroutable__' (matching the
2620
- // existing unroutable bucket convention).
2621
- const db = openHippoDb(opts.hippoRoot);
2622
- try {
2623
- const parseFailTenant = teamIdFromRaw !== null ? resolveTenantForTeam(db, teamIdFromRaw) : null;
2624
- writeToDlq(db, {
2625
- tenantId: parseFailTenant, // null → '__unroutable__' sentinel
2626
- teamId: teamIdFromRaw,
2627
- rawPayload: rawBody,
2628
- error: 'invalid JSON',
2629
- bucket: 'parse_error',
2630
- signature: sigStr,
2631
- slackTimestamp: tsStr,
2632
- });
2633
- }
2634
- finally {
2635
- closeHippoDb(db);
2636
- }
2637
- sendJson(res, 200, { ok: true, status: 'dlq' });
2638
- return;
2639
- }
2640
- if (isJsonObjectRecord(body)) {
2641
- const bodyRecord = body;
2642
- if (bodyRecord.type === 'url_verification') {
2643
- sendJson(res, 200, {
2644
- challenge: String(bodyRecord.challenge ?? ''),
2645
- });
2646
- return;
2647
- }
2648
- }
2649
- // Resolve tenant. v0.39 fail-closed: when slack_workspaces is non-empty
2650
- // and the team_id is unknown, resolveTenantForTeam returns null and we
2651
- // park the envelope in slack_dlq with bucket='unroutable'. Mandatory ACK
2652
- // 200 so Slack stops retrying; do NOT call ingest.
2653
- let resolvedTenant = null;
2654
- if (body !== undefined && isSlackEventEnvelope(body)) {
2655
- const db = openHippoDb(opts.hippoRoot);
2656
- try {
2657
- resolvedTenant = resolveTenantForTeam(db, body.team_id);
2658
- }
2659
- finally {
2660
- closeHippoDb(db);
2661
- }
2662
- if (resolvedTenant === null) {
2663
- const db2 = openHippoDb(opts.hippoRoot);
2664
- try {
2665
- writeToDlq(db2, {
2666
- tenantId: null, // unroutable — stored as '__unroutable__'
2667
- teamId: body.team_id,
2668
- rawPayload: rawBody,
2669
- error: `unroutable team_id: ${body.team_id}`,
2670
- bucket: 'unroutable',
2671
- signature: sigStr,
2672
- slackTimestamp: tsStr,
2673
- });
2674
- }
2675
- finally {
2676
- closeHippoDb(db2);
2677
- }
2678
- sendJson(res, 200, { ok: true, status: 'dlq' });
2679
- return;
2680
- }
2681
- }
2682
- else {
2683
- // Non-envelope payload: use env tenant for the DLQ row's bookkeeping.
2684
- resolvedTenant = process.env.HIPPO_TENANT ?? 'default';
2685
- }
2686
- const ctx = {
2687
- hippoRoot: opts.hippoRoot,
2688
- tenantId: resolvedTenant,
2689
- actor: adminActor('connector:slack'),
2690
- };
2691
- if (body === undefined || !isSlackEventEnvelope(body)) {
2692
- const db = openHippoDb(ctx.hippoRoot);
2693
- try {
2694
- writeToDlq(db, {
2695
- tenantId: ctx.tenantId,
2696
- teamId: teamIdFromRaw,
2697
- rawPayload: rawBody,
2698
- error: 'not an event_callback envelope',
2699
- bucket: 'parse_error',
2700
- signature: sigStr,
2701
- slackTimestamp: tsStr,
2702
- });
2703
- }
2704
- finally {
2705
- closeHippoDb(db);
2706
- }
2707
- sendJson(res, 200, { ok: true, status: 'dlq' });
2708
- return;
2709
- }
2710
- const inner = body.event;
2711
- if (isSlackMessageEvent(inner)) {
2712
- if (inner.subtype === 'message_deleted' && inner.deleted_ts) {
2713
- const r = handleMessageDeleted(ctx, {
2714
- teamId: body.team_id,
2715
- channelId: inner.channel,
2716
- deletedTs: inner.deleted_ts,
2717
- eventId: body.event_id,
2718
- });
2719
- sendJson(res, 200, { ok: true, status: r.status });
2720
- return;
2721
- }
2722
- const r = ingestMessage(ctx, {
2723
- teamId: body.team_id,
2724
- // channel privacy isn't on the inner event; use channel_type as a
2725
- // proxy. 'group'|'im'|'mpim' → private. 'channel' → public. Unknown
2726
- // → private (fail closed).
2727
- channel: {
2728
- id: inner.channel,
2729
- is_private: inner.channel_type !== 'channel',
2730
- is_im: inner.channel_type === 'im',
2731
- is_mpim: inner.channel_type === 'mpim',
2732
- },
2733
- message: inner,
2734
- eventId: body.event_id,
2735
- });
2736
- sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
2737
- return;
2738
- }
2739
- const db = openHippoDb(ctx.hippoRoot);
2740
- try {
2741
- writeToDlq(db, {
2742
- tenantId: ctx.tenantId,
2743
- teamId: body.team_id,
2744
- rawPayload: rawBody,
2745
- error: `unhandled event type: ${inner.type ?? 'unknown'}`,
2746
- bucket: 'parse_error',
2747
- signature: sigStr,
2748
- slackTimestamp: tsStr,
2749
- });
2750
- }
2751
- finally {
2752
- closeHippoDb(db);
2753
- }
2754
- sendJson(res, 200, { ok: true, status: 'dlq' });
2569
+ await handleSlackEventsWebhook({ req, res, opts });
2755
2570
  return;
2756
2571
  }
2757
- // ── POST /v1/connectors/github/events ──
2758
- //
2759
- // GitHub webhook receiver. Mirrors the Slack route shape but with
2760
- // GitHub-specific idioms:
2761
- // 1. HMAC SHA-256 over the raw body (X-Hub-Signature-256), no timestamp.
2762
- // 2. Event type discriminated by the X-GitHub-Event header (not body.type).
2763
- // 3. X-GitHub-Delivery is required audit metadata (NOT the dedupe seam — see
2764
- // computeIdempotencyKey, which folds the signed body into the key so a
2765
- // replayed body with a fresh delivery UUID still dedupes).
2766
- // 4. Tenant resolved by installation.id → github_installations, then by
2767
- // repository.full_name → github_repositories (PAT-mode multi-tenant).
2768
- // 5. ALWAYS ACK 200 on signed envelopes (DLQ included). 401 only on bad
2769
- // signature; 404 only when GITHUB_WEBHOOK_SECRET is unset (don't expose
2770
- // the route's existence on builds where it's gated off).
2771
2572
  if (method === 'POST' && path === '/v1/connectors/github/events') {
2772
2573
  if (!isPublicRoute(method, path)) {
2773
2574
  throw new HttpError(401, 'auth required');
2774
2575
  }
2775
- const rawBody = await readBody(req);
2776
- const secret = process.env.GITHUB_WEBHOOK_SECRET;
2777
- if (!secret) {
2778
- res.writeHead(404, JSON_HEADERS);
2779
- res.end(JSON.stringify({ error: 'not found' }));
2780
- return;
2781
- }
2782
- const previousSecret = process.env.GITHUB_WEBHOOK_SECRET_PREVIOUS;
2783
- const sigHdr = req.headers['x-hub-signature-256'];
2784
- const eventHdr = req.headers['x-github-event'];
2785
- const deliveryHdr = req.headers['x-github-delivery'];
2786
- const sigStr = isHeaderString(sigHdr) ? sigHdr : null;
2787
- const eventName = isHeaderString(eventHdr) ? eventHdr : null;
2788
- const deliveryId = isHeaderString(deliveryHdr) ? deliveryHdr : null;
2789
- if (sigStr === null ||
2790
- !verifyGitHubSignature({
2791
- rawBody,
2792
- signature: sigStr,
2793
- webhookSecret: secret,
2794
- previousSecret,
2795
- })) {
2796
- throw new HttpError(401, 'invalid GitHub signature');
2797
- }
2798
- // Signature OK from here on. Everything else is ACK-200; bad envelopes go
2799
- // to the DLQ and a human can replay later.
2800
- // Cheap regex extraction of installation_id / repo for DLQ rows that fail
2801
- // to JSON.parse — gives operators something to triage.
2802
- const installationFromRaw = (() => {
2803
- const m = rawBody.match(/"installation"\s*:\s*\{[^}]*"id"\s*:\s*(\d+)/);
2804
- return m ? m[1] : null;
2805
- })();
2806
- const repoFromRaw = (() => {
2807
- const m = rawBody.match(/"full_name"\s*:\s*"([^"]+)"/);
2808
- return m ? m[1] : null;
2809
- })();
2810
- if (deliveryId === null) {
2811
- // Body was signed but caller omitted the audit header. Park.
2812
- const db = openHippoDb(opts.hippoRoot);
2813
- try {
2814
- writeToGitHubDlq(db, {
2815
- tenantId: process.env.HIPPO_TENANT ?? 'default',
2816
- rawPayload: rawBody,
2817
- error: 'missing X-GitHub-Delivery header',
2818
- bucket: 'parse_error',
2819
- eventName,
2820
- deliveryId: null,
2821
- signature: sigStr,
2822
- installationId: installationFromRaw,
2823
- repoFullName: repoFromRaw,
2824
- });
2825
- }
2826
- finally {
2827
- closeHippoDb(db);
2828
- }
2829
- sendJson(res, 200, { ok: true, status: 'dlq' });
2830
- return;
2831
- }
2832
- // Ping fires once at hook creation. Don't ingest, don't DLQ — just pong.
2833
- if (eventName === 'ping') {
2834
- sendJson(res, 200, { pong: true });
2835
- return;
2836
- }
2837
- const ALLOWED_EVENTS = new Set([
2838
- 'issues',
2839
- 'issue_comment',
2840
- 'pull_request',
2841
- 'pull_request_review_comment',
2842
- ]);
2843
- if (eventName === null || !ALLOWED_EVENTS.has(eventName)) {
2844
- const db = openHippoDb(opts.hippoRoot);
2845
- try {
2846
- writeToGitHubDlq(db, {
2847
- tenantId: process.env.HIPPO_TENANT ?? 'default',
2848
- rawPayload: rawBody,
2849
- error: `unhandled event: ${eventName ?? '(missing X-GitHub-Event)'}`,
2850
- bucket: 'unhandled',
2851
- eventName,
2852
- deliveryId,
2853
- signature: sigStr,
2854
- installationId: installationFromRaw,
2855
- repoFullName: repoFromRaw,
2856
- });
2857
- }
2858
- finally {
2859
- closeHippoDb(db);
2860
- }
2861
- sendJson(res, 200, { ok: true, status: 'dlq' });
2862
- return;
2863
- }
2864
- let body;
2865
- try {
2866
- body = JSON.parse(rawBody);
2867
- }
2868
- catch {
2869
- const db = openHippoDb(opts.hippoRoot);
2870
- try {
2871
- writeToGitHubDlq(db, {
2872
- tenantId: process.env.HIPPO_TENANT ?? 'default',
2873
- rawPayload: rawBody,
2874
- error: 'invalid JSON',
2875
- bucket: 'parse_error',
2876
- eventName,
2877
- deliveryId,
2878
- signature: sigStr,
2879
- installationId: installationFromRaw,
2880
- repoFullName: repoFromRaw,
2881
- });
2882
- }
2883
- finally {
2884
- closeHippoDb(db);
2885
- }
2886
- sendJson(res, 200, { ok: true, status: 'dlq' });
2887
- return;
2888
- }
2889
- if (body === undefined || !isGitHubWebhookEnvelope(body)) {
2890
- const db = openHippoDb(opts.hippoRoot);
2891
- try {
2892
- writeToGitHubDlq(db, {
2893
- tenantId: process.env.HIPPO_TENANT ?? 'default',
2894
- rawPayload: rawBody,
2895
- error: 'not a GitHub webhook envelope',
2896
- bucket: 'parse_error',
2897
- eventName,
2898
- deliveryId,
2899
- signature: sigStr,
2900
- installationId: installationFromRaw,
2901
- repoFullName: repoFromRaw,
2902
- });
2903
- }
2904
- finally {
2905
- closeHippoDb(db);
2906
- }
2907
- sendJson(res, 200, { ok: true, status: 'dlq' });
2908
- return;
2909
- }
2910
- const installationId = body.installation?.id != null ? String(body.installation.id) : null;
2911
- const repoFullName = body.repository?.full_name ?? null;
2912
- // Tenant resolution. Fail closed on multi-tenant installs with unknown
2913
- // routing — same policy as Slack.
2914
- let resolvedTenant;
2915
- {
2916
- const db = openHippoDb(opts.hippoRoot);
2917
- try {
2918
- resolvedTenant = resolveTenantForGitHub(db, {
2919
- installationId,
2920
- repoFullName,
2921
- });
2922
- }
2923
- finally {
2924
- closeHippoDb(db);
2925
- }
2926
- }
2927
- if (resolvedTenant === null) {
2928
- const db = openHippoDb(opts.hippoRoot);
2929
- try {
2930
- writeToGitHubDlq(db, {
2931
- tenantId: null,
2932
- rawPayload: rawBody,
2933
- error: `unroutable: installation_id=${installationId ?? '(none)'} repo=${repoFullName ?? '(none)'}`,
2934
- bucket: 'unroutable',
2935
- eventName,
2936
- deliveryId,
2937
- signature: sigStr,
2938
- installationId,
2939
- repoFullName,
2940
- });
2941
- }
2942
- finally {
2943
- closeHippoDb(db);
2944
- }
2945
- sendJson(res, 200, { ok: true, status: 'dlq' });
2946
- return;
2947
- }
2948
- const ctx = {
2949
- hippoRoot: opts.hippoRoot,
2950
- tenantId: resolvedTenant,
2951
- actor: adminActor('connector:github'),
2952
- };
2953
- // Dispatch by event header. Type guards cross-check the body shape against
2954
- // the header so a payload of one event type cannot satisfy another's guard.
2955
- if (eventName === 'issues' && isGitHubIssueEvent(body, 'issues')) {
2956
- if (body.action === 'deleted') {
2957
- // GitHub does fire issues.deleted (admin-initiated). Don't archive — V1
2958
- // policy is to log and let an operator decide. Archive could lose the
2959
- // memory if the issue is being moved between accounts.
2960
- const db = openHippoDb(opts.hippoRoot);
2961
- try {
2962
- writeToGitHubDlq(db, {
2963
- tenantId: resolvedTenant,
2964
- rawPayload: rawBody,
2965
- error: 'issues.deleted requires manual review',
2966
- bucket: 'unhandled',
2967
- eventName,
2968
- deliveryId,
2969
- signature: sigStr,
2970
- installationId,
2971
- repoFullName,
2972
- });
2973
- }
2974
- finally {
2975
- closeHippoDb(db);
2976
- }
2977
- sendJson(res, 200, { ok: true, status: 'dlq' });
2978
- return;
2979
- }
2980
- const ingestInput = { eventName: 'issues', payload: body };
2981
- const r = ingestGitHubEvent(ctx, { event: ingestInput, rawBody, deliveryId });
2982
- sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
2983
- return;
2984
- }
2985
- if (eventName === 'issue_comment' && isGitHubIssueCommentEvent(body, 'issue_comment')) {
2986
- if (body.action === 'deleted') {
2987
- const repo = body.repository?.full_name ?? '';
2988
- const artifactRef = `github://${repo}/issue/${body.issue.number}/comment/${body.comment.id}`;
2989
- // v1.3.2: deletion key uses a 'deleted:' namespace so it doesn't collide
2990
- // with the ingest path's key for the same artifact. Without the prefix,
2991
- // a previously-ingested comment's log row would make hasSeenKey return
2992
- // true on the first deletion, short-circuiting archive. Codex round 3
2993
- // P0 fix evolved through two iterations to land here.
2994
- const idempotencyKey = computeGitHubDeletionKey(artifactRef, body.comment.updated_at ?? null);
2995
- const r = handleGitHubCommentDeleted(ctx, {
2996
- artifactRef,
2997
- idempotencyKey,
2998
- deliveryId,
2999
- eventName,
3000
- });
3001
- sendJson(res, 200, { ok: true, status: r.status, archivedCount: r.archivedCount });
3002
- return;
3003
- }
3004
- const ingestInput = { eventName: 'issue_comment', payload: body };
3005
- const r = ingestGitHubEvent(ctx, { event: ingestInput, rawBody, deliveryId });
3006
- sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
3007
- return;
3008
- }
3009
- if (eventName === 'pull_request' && isGitHubPullRequestEvent(body, 'pull_request')) {
3010
- const ingestInput = { eventName: 'pull_request', payload: body };
3011
- const r = ingestGitHubEvent(ctx, { event: ingestInput, rawBody, deliveryId });
3012
- sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
3013
- return;
3014
- }
3015
- if (eventName === 'pull_request_review_comment' &&
3016
- isGitHubPullRequestReviewCommentEvent(body, 'pull_request_review_comment')) {
3017
- if (body.action === 'deleted') {
3018
- const repo = body.repository?.full_name ?? '';
3019
- const artifactRef = `github://${repo}/pull/${body.pull_request.number}/review_comment/${body.comment.id}`;
3020
- // v1.3.2: see issue_comment branch comment above for the namespace rationale.
3021
- const idempotencyKey = computeGitHubDeletionKey(artifactRef, body.comment.updated_at ?? null);
3022
- const r = handleGitHubCommentDeleted(ctx, {
3023
- artifactRef,
3024
- idempotencyKey,
3025
- deliveryId,
3026
- eventName,
3027
- });
3028
- sendJson(res, 200, { ok: true, status: r.status, archivedCount: r.archivedCount });
3029
- return;
3030
- }
3031
- const ingestInput = {
3032
- eventName: 'pull_request_review_comment',
3033
- payload: body,
3034
- };
3035
- const r = ingestGitHubEvent(ctx, { event: ingestInput, rawBody, deliveryId });
3036
- sendJson(res, 200, { ok: true, status: r.status, memoryId: r.memoryId });
3037
- return;
3038
- }
3039
- // Header allow-listed but body shape didn't satisfy the matching guard.
3040
- {
3041
- const db = openHippoDb(opts.hippoRoot);
3042
- try {
3043
- writeToGitHubDlq(db, {
3044
- tenantId: resolvedTenant,
3045
- rawPayload: rawBody,
3046
- error: `body shape did not match X-GitHub-Event=${eventName}`,
3047
- bucket: 'parse_error',
3048
- eventName,
3049
- deliveryId,
3050
- signature: sigStr,
3051
- installationId,
3052
- repoFullName,
3053
- });
3054
- }
3055
- finally {
3056
- closeHippoDb(db);
3057
- }
3058
- sendJson(res, 200, { ok: true, status: 'dlq' });
3059
- return;
3060
- }
2576
+ await handleGitHubEventsWebhook({ req, res, opts });
2577
+ return;
3061
2578
  }
3062
2579
  // ── MCP-over-HTTP/SSE transport (Task 11) ──
3063
2580
  //