@tpsdev-ai/flair 0.51.1 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +10 -5
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +575 -547
  4. package/dist/doctor-client.js +35 -0
  5. package/dist/hook-install.js +74 -0
  6. package/dist/install/global-bin-path.js +14 -0
  7. package/dist/lib/auth-resolve.js +15 -0
  8. package/dist/lib/doctor-run.js +28 -15
  9. package/dist/lib/upgrade-exec-path.js +257 -0
  10. package/dist/lib/upgrade-plain-tree.js +558 -0
  11. package/dist/rem/promote-policy.js +204 -0
  12. package/dist/rem/restore.js +55 -15
  13. package/dist/rem/runner.js +203 -20
  14. package/dist/resources/AdminMemory.js +2 -1
  15. package/dist/resources/AgentSeed.js +26 -10
  16. package/dist/resources/Asset.js +203 -0
  17. package/dist/resources/AutoPromoteCandidates.js +2 -4
  18. package/dist/resources/Credential.js +14 -0
  19. package/dist/resources/Federation.js +80 -0
  20. package/dist/resources/Integration.js +12 -0
  21. package/dist/resources/Memory.js +158 -60
  22. package/dist/resources/MemoryBootstrap.js +63 -20
  23. package/dist/resources/MemoryCandidate.js +12 -0
  24. package/dist/resources/MemoryConsolidate.js +2 -1
  25. package/dist/resources/MemoryDedupStats.js +17 -2
  26. package/dist/resources/MemoryFeed.js +30 -0
  27. package/dist/resources/MemoryGrant.js +14 -0
  28. package/dist/resources/MemoryReflect.js +75 -17
  29. package/dist/resources/Message.js +190 -0
  30. package/dist/resources/OrgEvent.js +12 -0
  31. package/dist/resources/PromoteMemoryCandidate.js +76 -0
  32. package/dist/resources/RecordUsage.js +1 -1
  33. package/dist/resources/Relationship.js +12 -0
  34. package/dist/resources/SemanticSearch.js +45 -13
  35. package/dist/resources/Soul.js +54 -18
  36. package/dist/resources/WorkspaceState.js +12 -0
  37. package/dist/resources/auth-middleware.js +17 -44
  38. package/dist/resources/authority-field-guard.js +37 -0
  39. package/dist/resources/bm25-index-service.js +1 -1
  40. package/dist/resources/bm25-index.js +50 -11
  41. package/dist/resources/embedding-space-guard.js +238 -0
  42. package/dist/resources/embeddings-provider.js +32 -5
  43. package/dist/resources/federation-classify.js +23 -1
  44. package/dist/resources/health.js +11 -2
  45. package/dist/resources/hit-tracking.js +244 -0
  46. package/dist/resources/mcp-tools.js +272 -7
  47. package/dist/resources/memory-reflect-lib.js +111 -0
  48. package/dist/resources/migrations/embedding-stamp.js +22 -4
  49. package/dist/resources/owner-field-guard.js +62 -0
  50. package/dist/resources/promotion-stamp.js +29 -0
  51. package/dist/resources/record-owner-guard.js +71 -5
  52. package/dist/resources/record-types.js +30 -7
  53. package/dist/resources/relay-lib.js +205 -0
  54. package/dist/resources/relay-ops.js +294 -0
  55. package/dist/resources/skill-write.js +120 -0
  56. package/dist/resources/soul-adk-guard.js +68 -0
  57. package/dist/resources/soul-write-policy.js +63 -0
  58. package/dist/resources/table-helpers.js +2 -0
  59. package/dist/resources/usage-recording.js +3 -3
  60. package/dist/src/rem/promote-policy.js +204 -0
  61. package/docs/api-reference.md +374 -0
  62. package/docs/auth.md +52 -0
  63. package/docs/federation.md +4 -0
  64. package/docs/integrations.md +6 -6
  65. package/docs/mcp-clients.md +16 -1
  66. package/docs/releasing.md +11 -8
  67. package/docs/rem.md +20 -2
  68. package/docs/upgrade.md +47 -2
  69. package/package.json +13 -8
  70. package/schemas/memory.graphql +51 -2
  71. package/schemas/message.graphql +74 -0
@@ -0,0 +1,203 @@
1
+ import { createBlob, databases } from "harper";
2
+ import { resolveAgentAuth } from "./agent-auth.js";
3
+ import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
4
+ import { FORBIDDEN, UNAUTH, makeAuthGate, makeByIdReadGate, makeReadScope, resolveAuthGate, stampAttribution, } from "./record-type-kit.js";
5
+ import { RECORD_TYPES } from "./record-types.js";
6
+ // Kit parameters read FROM RECORD_TYPES.Asset (single source of truth), same
7
+ // composition MemoryCandidate.ts uses.
8
+ const assetReadScope = makeReadScope(RECORD_TYPES.Asset.readScope, RECORD_TYPES.Asset.ownerField);
9
+ const assetByIdReadGate = makeByIdReadGate(assetReadScope);
10
+ const assetAuthGate = makeAuthGate();
11
+ /**
12
+ * Asset — a binary blob (screenshot/image) owned by an agent and linked to a
13
+ * Memory. Storage slice (images-in-Flair slice 1): lets a spoke (e.g. the
14
+ * Visual Memory Vault) write image bytes into the hub as a Harper Blob and read
15
+ * them back, owner-scoped. No MCP surface yet (RECORD_TYPES.Asset carries no
16
+ * `mcp` field); serving assets through `/mcp` is a separate, security-reviewed
17
+ * slice.
18
+ *
19
+ * Read: identity-gated (anonymous HTTP denied) AND owner-only (an agent sees
20
+ * only its own assets; by-id reads 404-never-403 to avoid an id oracle).
21
+ * Write: self-enforcing — a non-admin agent may create/modify/delete only its
22
+ * own assets (`agentId` no-forge attribution, same idiom as WorkspaceState:
23
+ * stamp on create, validate on update). Admin/internal calls pass unfiltered.
24
+ *
25
+ * Blob handling: a base64 `data` string is decoded and wrapped with
26
+ * `createBlob(..., { type: contentType })` so the bytes are stored decoded and
27
+ * out-of-record, not as the literal base64 text Harper's string coercion would
28
+ * otherwise persist. Write-time gates (Sherlock STOP, this slice): decoded
29
+ * size is capped at MAX_ASSET_DECODED_BYTES, and `contentType` must be an
30
+ * allowlisted `image/*` (XML/SVG subtypes rejected) so a mistyped or
31
+ * unbounded blob cannot land. Non-string `data` without a readable size is
32
+ * rejected (400) rather than persisted unbounded.
33
+ *
34
+ * Lifecycle (Kern P1, this slice): an Asset is retained until its owner
35
+ * deletes the row. Deleting or superseding the parent Memory does not sweep
36
+ * linked blobs — no GC here. Slice 2's serving tool must 404 a dangling
37
+ * memoryId/assetId; the GC sweep lands with that slice. `updatedAt` is
38
+ * stamped on every write so that sweep can key on recency without a second
39
+ * schema change. Harper unlinks blob files when the Asset row is deleted.
40
+ *
41
+ * `memoryId` is an unvalidated, mutable string this slice (exist-and-owned
42
+ * check deferred). Harmless for owner-only reads; slice 2's OAuth-scoped
43
+ * asset URL in memory_search must not assume the parent Memory still exists
44
+ * or is owned by the writer.
45
+ */
46
+ export class Asset extends databases.flair.Asset {
47
+ allowRead() { return assetAuthGate.call(this); }
48
+ async get(target) {
49
+ if (!target || (typeof target === "object" && target.isCollection)) {
50
+ return this.search(target);
51
+ }
52
+ return assetByIdReadGate.call(this, target, (t) => super.get(t));
53
+ }
54
+ async search(query) {
55
+ const ctx = this.getContext?.();
56
+ const gate = await resolveAuthGate(ctx, UNAUTH());
57
+ if (gate.kind === "denied")
58
+ return gate.response;
59
+ if (gate.kind === "unfiltered")
60
+ return super.search(query);
61
+ const scope = await assetReadScope(gate.agentId);
62
+ const agentCondition = scope.condition;
63
+ if (!query?.conditions) {
64
+ return super.search({ conditions: [agentCondition], ...(query || {}) });
65
+ }
66
+ return super.search({
67
+ ...query,
68
+ conditions: [agentCondition, { conditions: query.conditions, operator: query.operator || "and" }],
69
+ operator: "and",
70
+ });
71
+ }
72
+ async post(content) {
73
+ const ctx = this.getContext?.();
74
+ const auth = await resolveAgentAuth(ctx);
75
+ if (auth.kind === "anonymous")
76
+ return UNAUTH();
77
+ const attr = stampAttribution(auth, content, RECORD_TYPES.Asset.ownerField, RECORD_TYPES.Asset.attribution.post, "forbidden: cannot store an asset for another agent");
78
+ if (attr.denied)
79
+ return attr.denied;
80
+ const blobDenial = _coerceBlob(content);
81
+ if (blobDenial)
82
+ return blobDenial;
83
+ content.createdAt ||= new Date().toISOString();
84
+ content.updatedAt = new Date().toISOString();
85
+ return super.post(content);
86
+ }
87
+ async patch(content, query) {
88
+ const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, RECORD_TYPES.Asset.ownerField);
89
+ if (denial)
90
+ return denial;
91
+ const blobDenial = _coerceBlob(content);
92
+ if (blobDenial)
93
+ return blobDenial;
94
+ content.updatedAt = new Date().toISOString();
95
+ return super.patch(content, query);
96
+ }
97
+ async put(content) {
98
+ const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, RECORD_TYPES.Asset.ownerField);
99
+ if (denial)
100
+ return denial;
101
+ const ctx = this.getContext?.();
102
+ const auth = await resolveAgentAuth(ctx);
103
+ if (auth.kind === "anonymous")
104
+ return UNAUTH();
105
+ const attr = stampAttribution(auth, content, RECORD_TYPES.Asset.ownerField, RECORD_TYPES.Asset.attribution.put, "forbidden: cannot modify an asset owned by another agent");
106
+ if (attr.denied)
107
+ return attr.denied;
108
+ const blobDenial = _coerceBlob(content);
109
+ if (blobDenial)
110
+ return blobDenial;
111
+ content.updatedAt = new Date().toISOString();
112
+ return super.put(content);
113
+ }
114
+ async delete(id, context) {
115
+ const ctx = this.getContext?.();
116
+ const gate = await resolveAuthGate(ctx, UNAUTH());
117
+ if (gate.kind === "denied")
118
+ return gate.response;
119
+ if (gate.kind === "unfiltered")
120
+ return super.delete(id, context);
121
+ const record = await super.get(id);
122
+ if (!record)
123
+ return super.delete(id, context);
124
+ if (record[RECORD_TYPES.Asset.ownerField] !== gate.agentId) {
125
+ return FORBIDDEN("forbidden: cannot delete an asset owned by another agent");
126
+ }
127
+ return super.delete(id, context);
128
+ }
129
+ }
130
+ /** Decoded payload cap. Screenshots fit; a multi-GB write is a DoS, not a photo. */
131
+ export const MAX_ASSET_DECODED_BYTES = 10 * 1024 * 1024;
132
+ const MAX_ASSET_BASE64_CHARS = Math.ceil(MAX_ASSET_DECODED_BYTES * 4 / 3) + 8;
133
+ const BAD_REQUEST = (msg) => new Response(JSON.stringify({ error: msg }), { status: 400, headers: { "Content-Type": "application/json" } });
134
+ /**
135
+ * Allow `image/*` except XML/SVG subtypes (XSS-capable). Parameters after `;`
136
+ * are stripped; `image/jpg` normalizes to `image/jpeg`.
137
+ */
138
+ function normalizeAssetContentType(raw) {
139
+ if (typeof raw !== "string")
140
+ return null;
141
+ let base = raw.split(";", 1)[0].trim().toLowerCase();
142
+ if (base === "image/jpg")
143
+ base = "image/jpeg";
144
+ if (!base.startsWith("image/"))
145
+ return null;
146
+ const subtype = base.slice("image/".length);
147
+ if (!subtype || subtype.includes("/") || subtype === "svg+xml" || subtype.endsWith("+xml"))
148
+ return null;
149
+ return base;
150
+ }
151
+ function decodedSizeOf(data) {
152
+ if (typeof data?.byteLength === "number")
153
+ return data.byteLength;
154
+ if (typeof data?.length === "number")
155
+ return data.length;
156
+ if (typeof data?.size === "number")
157
+ return data.size;
158
+ return undefined;
159
+ }
160
+ /**
161
+ * Decode a base64 `data` string into a Harper Blob with the record's MIME type.
162
+ * A caller may also pass an already-created Blob/Buffer, which is left as-is for
163
+ * Harper's own coercion (size still capped when readable). No-op when `data`
164
+ * is absent and `contentType` is not being written.
165
+ *
166
+ * Returns a 400 Response when the write would persist an unbounded or
167
+ * mistyped blob; otherwise undefined.
168
+ */
169
+ function _coerceBlob(content) {
170
+ if (!content)
171
+ return;
172
+ const hasType = content.contentType != null && content.contentType !== "";
173
+ if (hasType) {
174
+ const normalized = normalizeAssetContentType(content.contentType);
175
+ if (!normalized) {
176
+ return BAD_REQUEST("invalid contentType: must be an allowlisted image MIME type");
177
+ }
178
+ content.contentType = normalized;
179
+ }
180
+ if (content.data == null)
181
+ return;
182
+ if (!hasType) {
183
+ return BAD_REQUEST("contentType is required when writing asset data");
184
+ }
185
+ if (typeof content.data === "string") {
186
+ if (content.data.length > MAX_ASSET_BASE64_CHARS) {
187
+ return BAD_REQUEST(`asset exceeds ${MAX_ASSET_DECODED_BYTES} byte decoded size cap`);
188
+ }
189
+ const decoded = Buffer.from(content.data, "base64");
190
+ if (decoded.length > MAX_ASSET_DECODED_BYTES) {
191
+ return BAD_REQUEST(`asset exceeds ${MAX_ASSET_DECODED_BYTES} byte decoded size cap`);
192
+ }
193
+ content.data = createBlob(decoded, { type: content.contentType });
194
+ return;
195
+ }
196
+ const size = decodedSizeOf(content.data);
197
+ if (typeof size !== "number") {
198
+ return BAD_REQUEST("asset data must be a base64 string or a sized binary payload");
199
+ }
200
+ if (size > MAX_ASSET_DECODED_BYTES) {
201
+ return BAD_REQUEST(`asset exceeds ${MAX_ASSET_DECODED_BYTES} byte decoded size cap`);
202
+ }
203
+ }
@@ -1,3 +1,4 @@
1
+ import { stampMemoryPromotionIsolated } from "./promotion-stamp.js";
1
2
  /**
2
3
  * POST /AutoPromoteCandidates (#1205b-2 — the UNATTENDED promotion path)
3
4
  *
@@ -147,10 +148,6 @@ export class AutoPromoteCandidates extends Resource {
147
148
  // scopeTag FIRST — the per-user access-control boundary (Req 2).
148
149
  tags: buildAutoPromotedTags(c.id, decision.scopeTag),
149
150
  derivedFrom: Array.isArray(c.sourceMemoryIds) ? c.sourceMemoryIds : [],
150
- promotionStatus: "approved",
151
- promotedAt: decidedAt,
152
- // Req 4 — non-impersonating machine reviewerId.
153
- promotedBy: decision.reviewerId,
154
151
  createdAt: decidedAt,
155
152
  };
156
153
  // ── The write — MEMORY ONLY ────────────────────────────────────────────
@@ -174,6 +171,7 @@ export class AutoPromoteCandidates extends Resource {
174
171
  skipped.push({ id: c.id, reason: `memory_write_rejected:${writeRes.status}` });
175
172
  continue;
176
173
  }
174
+ await stampMemoryPromotionIsolated(memId, decision.reviewerId, decidedAt, ctx);
177
175
  // ── Mark the candidate promoted (commit point) ─────────────────────────
178
176
  // Ordered AFTER the Memory write, matching the human promote path: the
179
177
  // safe failure state is a promoted Memory whose candidate is still pending
@@ -1,5 +1,6 @@
1
1
  import { databases } from "harper";
2
2
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
+ import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
3
4
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
4
5
  import { stampAttribution, UNAUTH } from "./record-type-kit.js";
5
6
  /**
@@ -115,7 +116,20 @@ export class Credential extends databases.flair.Credential {
115
116
  const { tokenHash, ...safe } = result;
116
117
  return safe;
117
118
  }
119
+ // PATCH routes past put(), so the owner-field rule below is enforced on both
120
+ // verbs via the one shared delegate. principalId owns a credential; a non-admin
121
+ // may rotate a token on its OWN credential but never re-point it at another
122
+ // principal. (Self-update stays legitimate — update:true is retained.)
123
+ async patch(content, query) {
124
+ const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "principalId");
125
+ if (denial)
126
+ return denial;
127
+ return super.patch(content, query);
128
+ }
118
129
  async put(content) {
130
+ const __ownerDenial = await guardOwnerFieldImmutable(this, () => super.get(), content, "principalId");
131
+ if (__ownerDenial)
132
+ return __ownerDenial;
119
133
  const auth = await resolveAgentAuth(this.getContext?.());
120
134
  if (auth.kind === "anonymous") {
121
135
  return new Response(JSON.stringify({ error: "authentication required" }), {
@@ -3,6 +3,9 @@ import { randomBytes } from "node:crypto";
3
3
  import nacl from "tweetnacl";
4
4
  import { allowAdmin } from "./agent-auth.js";
5
5
  import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, generateNonce, } from "./federation-crypto.js";
6
+ import { reconcileState } from "./relay-lib.js";
7
+ import { isSkillWrite } from "./skill-write.js";
8
+ import { noteWriteStamp } from "./embedding-space-guard.js";
6
9
  import { initFederationCleanup } from "./federation-cleanup.js";
7
10
  import { createPersistentNonceStore, initNonceStoreCleanup } from "./federation-nonce-store.js";
8
11
  import { classifyRecord, reconstructRecordVerifyBody, checkPrincipalEntitlement, } from "./federation-classify.js";
@@ -69,6 +72,30 @@ function mergeRecord(local, remote) {
69
72
  }
70
73
  return merged;
71
74
  }
75
+ /**
76
+ * embedding-space-guard slice 1 (the raw-writer-coverage hole K&S found in
77
+ * review #1554): the sync-in merge persists Memory rows via the RAW table
78
+ * handle, bypassing Memory.post()/put(). An LWW remote-win copies the REMOTE
79
+ * `embeddingModel` into `mergedData`, so a memory synced from a spoke on a
80
+ * different engine/model lands a FOREIGN-space vector. Without tripping the
81
+ * vector-space guard's latch, the next recall's O(1) "uniform" consult would
82
+ * cosine that foreign vector = mixed-space garbage — exactly the failure the
83
+ * guard exists to kill. Trip the latch here.
84
+ *
85
+ * `noteWriteStamp` no-ops for a current/bare stamp (a self-originated or
86
+ * same-space sync) and for a non-Memory table (no `embeddingModel`), so only a
87
+ * genuinely foreign Memory sync trips it. Kept as a note-ONLY helper — the raw
88
+ * `table.put(mergedData)` stays INLINE at the call site so the raw-writer
89
+ * coverage gates (authority-field-guard.test.ts / memory-embedding-writer-
90
+ * coverage.test.ts) still enumerate the Memory writer — while the trip decision
91
+ * itself is unit-testable without standing up the full signed sync-in path (see
92
+ * test/unit-isolated/embedding-space-guard-federation.test.ts).
93
+ */
94
+ export function noteFederationMergedMemory(recordTable, mergedData) {
95
+ if (recordTable === "Memory") {
96
+ noteWriteStamp(mergedData.embeddingModel);
97
+ }
98
+ }
72
99
  // ─── Instance identity ───────────────────────────────────────────────────────
73
100
  /**
74
101
  * GET /FederationInstance — return this instance's identity.
@@ -379,6 +406,21 @@ export class FederationSync extends Resource {
379
406
  Soul: databases.flair.Soul,
380
407
  Agent: databases.flair.Agent,
381
408
  Relationship: databases.flair.Relationship,
409
+ // Flair Relay (flair#1521). Registered so the policy + owner-field land now
410
+ // (the design's ship-order). No spoke pushes Message records in S1 — this is the
411
+ // RECEIVE side only; the absorbing-state guard below keeps an incoming
412
+ // Message merge from ever regressing a locally-consumed row (§12 P0-3).
413
+ //
414
+ // S2 COMMENT-PIN (Kern P1-4, flair#1521): the receive-only carve-out means
415
+ // an incoming Message record is verified only at the RECORD level (batch
416
+ // signature, per-record originator signature, principalId == `from`) — the
417
+ // ENVELOPE's own signature + contentHash (relay-lib.ts verifyMessageSignature)
418
+ // NEVER runs on this merge path. Correct for S1 (nothing pushes Message).
419
+ // But the FIRST hub that pushes Message would merge unverified envelope
420
+ // content silently: S2 MUST add envelope verification here (or an explicit
421
+ // trust delegation to the originator's send-time check) alongside adding
422
+ // Message to the push list — do not inherit this as a silent gap.
423
+ Message: databases.flair.Message,
382
424
  };
383
425
  const knownTables = new Set(Object.keys(tableMap));
384
426
  for (const record of records) {
@@ -454,10 +496,48 @@ export class FederationSync extends Resource {
454
496
  continue;
455
497
  }
456
498
  const mergedData = mergeRecord(local, record);
499
+ // ── flair#1542: skills are not federated ──
500
+ // A skill-tagged Memory is a local, gated artifact (SkillScan + forced
501
+ // durability on the write path). Merging a pushed skill-tagged row RAW
502
+ // would land an unscanned, possibly non-persistent skill — bypassing the
503
+ // gate. Skip it: skills are written locally via skill_store, never synced.
504
+ if (record.table === "Memory" && isSkillWrite(mergedData)) {
505
+ recordSkip("skill_not_federated");
506
+ continue;
507
+ }
508
+ // Absorbing-state guard for the Message table (flair#1521 §12 P0-3).
509
+ // Generic newer-wins LWW would let a deadline-sweep `failed` (or any
510
+ // other state) written on a peer overwrite a locally-CONSUMED message,
511
+ // telling the sender "failed" about a message the recipient actually
512
+ // consumed — the exact inversion Relay exists to kill. `consumed` is
513
+ // absorbing: reconcileState pins it regardless of updatedAt. The guard
514
+ // lives HERE, in the raw-put apply path, because sync-in bypasses the
515
+ // Message resource's methods (Federation.ts uses table.put directly).
516
+ if (record.table === "Message") {
517
+ const reconciled = reconcileState(local?.state, mergedData.state);
518
+ mergedData.state = reconciled;
519
+ // P1-3 (flair#1521): reconcile the SATELLITE lifecycle fields too, not
520
+ // just `state`. Generic LWW keeps the incoming (newer) record's
521
+ // failureReason/consumedAt, so a locally-CONSUMED row could end up
522
+ // `consumed` while carrying a remote `failureReason: "deadline"` — the
523
+ // sender-visible "failed" about a message the recipient actually
524
+ // consumed, the exact inversion the absorbing rule exists to kill. A
525
+ // consumed row NEVER has a failureReason; keep the consumed row's own
526
+ // consumedAt (local's when local was the consumer).
527
+ if (reconciled === "consumed") {
528
+ mergedData.failureReason = null;
529
+ mergedData.consumedAt = local?.consumedAt ?? mergedData.consumedAt ?? null;
530
+ }
531
+ }
457
532
  mergedData._originatorInstanceId = decision.originator;
458
533
  mergedData._syncedFrom = instanceId;
459
534
  mergedData._syncedAt = new Date().toISOString();
460
535
  await table.put(mergedData);
536
+ // embedding-space-guard slice 1: a federation-merged Memory can carry a
537
+ // FOREIGN embeddingModel (LWW remote-win) — trip the guard's latch so
538
+ // recall/dedup degrade instead of cosining a foreign vector. See
539
+ // noteFederationMergedMemory's doc.
540
+ noteFederationMergedMemory(record.table, mergedData);
461
541
  merged++;
462
542
  }
463
543
  catch (err) {
@@ -1,5 +1,6 @@
1
1
  import { databases } from "harper";
2
2
  import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
3
+ import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
3
4
  const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
4
5
  const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
5
6
  const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
@@ -89,7 +90,18 @@ export class Integration extends databases.flair.Integration {
89
90
  }
90
91
  return super.post(content, context);
91
92
  }
93
+ // PATCH routes past put(), so agentId immutability is enforced on both verbs
94
+ // via the one shared delegate.
95
+ async patch(content, query) {
96
+ const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
97
+ if (denial)
98
+ return denial;
99
+ return super.patch(content, query);
100
+ }
92
101
  async put(content, context) {
102
+ const __ownerDenial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
103
+ if (__ownerDenial)
104
+ return __ownerDenial;
93
105
  const auth = await this._auth();
94
106
  if (auth.kind === "anonymous")
95
107
  return UNAUTH();