@tpsdev-ai/flair 0.51.2 → 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.
- package/README.md +10 -5
- package/dist/build-info.json +3 -3
- package/dist/cli.js +575 -547
- package/dist/doctor-client.js +35 -0
- package/dist/hook-install.js +74 -0
- package/dist/install/global-bin-path.js +14 -0
- package/dist/lib/auth-resolve.js +15 -0
- package/dist/lib/doctor-run.js +28 -15
- package/dist/lib/upgrade-exec-path.js +257 -0
- package/dist/lib/upgrade-plain-tree.js +558 -0
- package/dist/rem/promote-policy.js +204 -0
- package/dist/rem/restore.js +55 -15
- package/dist/rem/runner.js +203 -20
- package/dist/resources/AdminMemory.js +2 -1
- package/dist/resources/AgentSeed.js +26 -10
- package/dist/resources/Asset.js +203 -0
- package/dist/resources/AutoPromoteCandidates.js +2 -4
- package/dist/resources/Credential.js +14 -0
- package/dist/resources/Federation.js +80 -0
- package/dist/resources/Integration.js +12 -0
- package/dist/resources/Memory.js +158 -60
- package/dist/resources/MemoryBootstrap.js +63 -20
- package/dist/resources/MemoryCandidate.js +12 -0
- package/dist/resources/MemoryConsolidate.js +2 -1
- package/dist/resources/MemoryDedupStats.js +17 -2
- package/dist/resources/MemoryFeed.js +30 -0
- package/dist/resources/MemoryGrant.js +14 -0
- package/dist/resources/MemoryReflect.js +75 -17
- package/dist/resources/Message.js +190 -0
- package/dist/resources/OrgEvent.js +12 -0
- package/dist/resources/PromoteMemoryCandidate.js +76 -0
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +12 -0
- package/dist/resources/SemanticSearch.js +45 -13
- package/dist/resources/Soul.js +54 -18
- package/dist/resources/WorkspaceState.js +12 -0
- package/dist/resources/auth-middleware.js +17 -44
- package/dist/resources/authority-field-guard.js +37 -0
- package/dist/resources/bm25-index-service.js +1 -1
- package/dist/resources/bm25-index.js +50 -11
- package/dist/resources/embedding-space-guard.js +238 -0
- package/dist/resources/embeddings-provider.js +32 -5
- package/dist/resources/federation-classify.js +23 -1
- package/dist/resources/health.js +11 -2
- package/dist/resources/hit-tracking.js +244 -0
- package/dist/resources/mcp-tools.js +272 -7
- package/dist/resources/memory-reflect-lib.js +111 -0
- package/dist/resources/migrations/embedding-stamp.js +22 -4
- package/dist/resources/owner-field-guard.js +62 -0
- package/dist/resources/promotion-stamp.js +29 -0
- package/dist/resources/record-owner-guard.js +71 -5
- package/dist/resources/record-types.js +30 -7
- package/dist/resources/relay-lib.js +205 -0
- package/dist/resources/relay-ops.js +294 -0
- package/dist/resources/skill-write.js +120 -0
- package/dist/resources/soul-adk-guard.js +68 -0
- package/dist/resources/soul-write-policy.js +63 -0
- package/dist/resources/table-helpers.js +2 -0
- package/dist/resources/usage-recording.js +3 -3
- package/dist/src/rem/promote-policy.js +204 -0
- package/docs/api-reference.md +374 -0
- package/docs/auth.md +52 -0
- package/docs/federation.md +4 -0
- package/docs/integrations.md +6 -6
- package/docs/mcp-clients.md +16 -1
- package/docs/releasing.md +11 -8
- package/docs/rem.md +20 -2
- package/docs/upgrade.md +47 -2
- package/package.json +6 -5
- package/schemas/memory.graphql +51 -2
- 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();
|