@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.
- 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 +13 -8
- package/schemas/memory.graphql +51 -2
- package/schemas/message.graphql +74 -0
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Resource, databases } from "harper";
|
|
2
2
|
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
3
|
import { getEmbedding, getMode } from "./embeddings-provider.js";
|
|
4
|
-
import {
|
|
4
|
+
import { isEmbeddingSpaceUniform, spaceGuardDiagnostics } from "./embedding-space-guard.js";
|
|
5
|
+
import { withDetachedTxn } from "./table-helpers.js";
|
|
6
|
+
import { applyHitStats, noteSearchHits } from "./hit-tracking.js";
|
|
5
7
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
6
8
|
import { resolveReadScope } from "./memory-read-scope.js";
|
|
7
9
|
// The BM25 + union-RRF hybrid path is feature-flagged via hybridEnabled()
|
|
@@ -61,7 +63,7 @@ export class SemanticSearch extends Resource {
|
|
|
61
63
|
// recall-harness (test/bench/recall-harness/run.ts) and `recall-eval.mjs`
|
|
62
64
|
// before reconsidering this default if the compositeScore formula or
|
|
63
65
|
// corpus changes.
|
|
64
|
-
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, includeMetadata = false, abstain = false, explain = false, includeLegs = false, includeArchived = false } = data || {};
|
|
66
|
+
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "raw", minScore = 0, since, asOf, includeTrust = false, includeMetadata = false, includeTrigger = false, abstain = false, explain = false, includeLegs = false, includeArchived = false } = data || {};
|
|
65
67
|
// Authenticated identity lives on the Harper Resource context (getContext().request).
|
|
66
68
|
// `this.request` is NOT populated on Harper v5 Resources — prior reads here
|
|
67
69
|
// silently returned undefined and the defense-in-depth scope check below
|
|
@@ -121,6 +123,24 @@ export class SemanticSearch extends Resource {
|
|
|
121
123
|
}
|
|
122
124
|
catch { }
|
|
123
125
|
}
|
|
126
|
+
// ─── Vector-space uniformity guard (embedding-space-guard slice 1) ────────
|
|
127
|
+
// If the store is NOT uniform in the current embedding space (a mixed-space
|
|
128
|
+
// corpus during a re-embed / model change), cosining the query against
|
|
129
|
+
// stored vectors returns garbage — Harper zero-pads a mismatched-dimension
|
|
130
|
+
// vector and returns a bogus score instead of throwing; same-dims /
|
|
131
|
+
// different-space is silent garbage too. REFUSE the embedding leg and
|
|
132
|
+
// degrade to keyword-only LOUDLY: drop qEmb so retrieveCandidates takes the
|
|
133
|
+
// keyword-only branch (reusing the existing mode:'none' graceful-degrade
|
|
134
|
+
// contract), and surface a structured warning naming both spaces + the
|
|
135
|
+
// `flair reembed` remedy below. Never serve mixed-space vectors, never
|
|
136
|
+
// merely log. Consulted through the SAME single chokepoint the write-time
|
|
137
|
+
// dedup leg uses (resources/embedding-space-guard.ts) — O(1) on the uniform
|
|
138
|
+
// happy path.
|
|
139
|
+
let spaceGuardDegraded = false;
|
|
140
|
+
if (qEmb && !(await isEmbeddingSpaceUniform())) {
|
|
141
|
+
qEmb = undefined;
|
|
142
|
+
spaceGuardDegraded = true;
|
|
143
|
+
}
|
|
124
144
|
// ─── Temporal intent detection ────────────────────────────────────────────
|
|
125
145
|
let sinceDate = since ? new Date(since) : null;
|
|
126
146
|
let temporalBoost = 1.0;
|
|
@@ -250,10 +270,17 @@ export class SemanticSearch extends Resource {
|
|
|
250
270
|
// with `includeMetadata: true`. `subject` needs no widening — it is
|
|
251
271
|
// already in DEFAULT_SELECT. Neither flag ⇒ select stays undefined ⇒
|
|
252
272
|
// response bytes unchanged.
|
|
253
|
-
|
|
273
|
+
// flair#1546: `includeTrigger` opts the skill recall signal into the
|
|
274
|
+
// projection. DEFAULT_SELECT deliberately omits `trigger` (a skill-only
|
|
275
|
+
// column — the shared retrieval core must not grow it for every consumer,
|
|
276
|
+
// same K&S projection ruling as `metadata`), so skill_search opts it in
|
|
277
|
+
// per-request to return the trigger in its lightweight catalog. Neither
|
|
278
|
+
// flag ⇒ select stays undefined ⇒ response bytes unchanged.
|
|
279
|
+
select: (includeTrust || includeMetadata || includeTrigger)
|
|
254
280
|
? [...DEFAULT_SELECT,
|
|
255
281
|
...(includeTrust ? ["provenance"] : []),
|
|
256
|
-
...(includeMetadata ? ["metadata"] : [])
|
|
282
|
+
...(includeMetadata ? ["metadata"] : []),
|
|
283
|
+
...(includeTrigger ? ["trigger"] : [])]
|
|
257
284
|
: undefined,
|
|
258
285
|
// flair#744 slice 2 + confidence-band refinement: attach the absolute
|
|
259
286
|
// per-result cosine confidence when the caller opts into abstention OR
|
|
@@ -291,15 +318,13 @@ export class SemanticSearch extends Resource {
|
|
|
291
318
|
// the final slice needs no additional sort. A cross-encoder rerank stage
|
|
292
319
|
// used to sit here and reorder the pool before this slice; it was removed
|
|
293
320
|
// in flair#893 after measuring Δp@3 = 0.000 at 4.1× query latency.
|
|
294
|
-
|
|
295
|
-
//
|
|
321
|
+
// Overlay committed hit stats BEFORE noting this search's increment so
|
|
322
|
+
// the response still shows the pre-hit count (same contract as the old
|
|
323
|
+
// fire-and-forget Memory patch, which ran after the slice).
|
|
324
|
+
const topResults = await Promise.all(filteredResults.slice(0, limit).map((r) => applyHitStats(r, ctx)));
|
|
325
|
+
// Async hit tracking — MemoryHitStat only, never a Memory rewrite.
|
|
296
326
|
const now = new Date().toISOString();
|
|
297
|
-
|
|
298
|
-
patchRecord(databases.flair.Memory, r.id, {
|
|
299
|
-
retrievalCount: (r.retrievalCount || 0) + 1,
|
|
300
|
-
lastRetrieved: now,
|
|
301
|
-
}).catch(() => { });
|
|
302
|
-
}
|
|
327
|
+
noteSearchHits(topResults.map((r) => r.id), now, ctx);
|
|
303
328
|
// flair#744 slice 1 — opt-in inline trust-evidence block. Assembled HERE,
|
|
304
329
|
// in the response tail, strictly AFTER read-scope resolution
|
|
305
330
|
// (retrieveCandidates + scope.isAllowed already ran) and purely for the
|
|
@@ -334,7 +359,14 @@ export class SemanticSearch extends Resource {
|
|
|
334
359
|
response.bestScore = abstention.bestScore;
|
|
335
360
|
response.threshold = abstention.threshold;
|
|
336
361
|
}
|
|
337
|
-
if (
|
|
362
|
+
if (spaceGuardDegraded) {
|
|
363
|
+
const diag = spaceGuardDiagnostics();
|
|
364
|
+
response._warning =
|
|
365
|
+
`semantic search unavailable — the store has mixed embedding spaces ` +
|
|
366
|
+
`(current: ${diag.current}; found: ${diag.found.join(", ")}); results are keyword-only. ` +
|
|
367
|
+
`Reconcile to one space with: flair reembed --stale-only`;
|
|
368
|
+
}
|
|
369
|
+
else if (!qEmb && q && getMode() === "none") {
|
|
338
370
|
response._warning = "semantic search unavailable — results are keyword-only";
|
|
339
371
|
}
|
|
340
372
|
// flair#1358: opt-in per-leg candidate ids for the bench instrument.
|
package/dist/resources/Soul.js
CHANGED
|
@@ -1,25 +1,21 @@
|
|
|
1
1
|
import { databases } from "harper";
|
|
2
|
-
import {
|
|
2
|
+
import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
|
|
3
3
|
import { localInstanceId } from "./instance-identity.js";
|
|
4
|
-
import { makeAuthGate, stampAttribution
|
|
4
|
+
import { makeAuthGate, stampAttribution } from "./record-type-kit.js";
|
|
5
5
|
import { RECORD_TYPES } from "./record-types.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* x-tps-agent), so an anonymous request — which carries no x-tps-agent — slipped
|
|
10
|
-
* through. With the non-rejecting gate, each write path self-enforces (resolveAgentAuth
|
|
11
|
-
* distinguishes internal/agent/anonymous). Mirrors the WorkspaceState pattern.
|
|
12
|
-
*
|
|
13
|
-
* No-forge attribution — mode/field drawn from RECORD_TYPES.Soul (record-
|
|
14
|
-
* types slice 2, flair#520) rather than hand-typed literals. "validate-
|
|
15
|
-
* truthy" (see record-type-kit.ts's stampAttribution doc) — rejects a
|
|
16
|
-
* PRESENT, mismatched agentId; passes through untouched when absent. Same
|
|
17
|
-
* idiom as Memory.post()/put().
|
|
18
|
-
*/
|
|
6
|
+
import { authorizeSoulWrite, refuseSoulWriteContent, soulProvenance } from "./soul-write-policy.js";
|
|
7
|
+
// Source authorization is independent of principal ownership: an admin runtime
|
|
8
|
+
// may manage records elsewhere, but it cannot author identity-defining Soul.
|
|
19
9
|
async function enforceWriteAuth(self, data) {
|
|
20
|
-
const auth = await
|
|
21
|
-
if (
|
|
22
|
-
return
|
|
10
|
+
const { auth, source, denied } = await authorizeSoulWrite(self.getContext?.());
|
|
11
|
+
if (denied)
|
|
12
|
+
return denied;
|
|
13
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
14
|
+
return new Response(JSON.stringify({ error: "soul_write_requires_one_record" }), {
|
|
15
|
+
status: 400, headers: { "Content-Type": "application/json" },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
data.provenance = soulProvenance(auth, source, new Date().toISOString());
|
|
23
19
|
const attr = stampAttribution(auth, data, RECORD_TYPES.Soul.ownerField, RECORD_TYPES.Soul.attribution.post, "forbidden: agentId must match authenticated agent");
|
|
24
20
|
return attr.denied ?? null;
|
|
25
21
|
}
|
|
@@ -43,6 +39,10 @@ export class Soul extends databases.flair.Soul {
|
|
|
43
39
|
const denied = await enforceWriteAuth(this, content);
|
|
44
40
|
if (denied)
|
|
45
41
|
return denied;
|
|
42
|
+
// Learned artifacts cannot gain identity authority through an operator write.
|
|
43
|
+
const learnedDenied = await refuseSoulWriteContent(content);
|
|
44
|
+
if (learnedDenied)
|
|
45
|
+
return learnedDenied;
|
|
46
46
|
content.durability ||= "permanent";
|
|
47
47
|
content.createdAt = new Date().toISOString();
|
|
48
48
|
content.updatedAt = content.createdAt;
|
|
@@ -56,10 +56,40 @@ export class Soul extends databases.flair.Soul {
|
|
|
56
56
|
}
|
|
57
57
|
return super.post(content, context);
|
|
58
58
|
}
|
|
59
|
+
// PATCH must validate the merged value and retained legacy tags, rather
|
|
60
|
+
// than treating an omitted field as proof that no learned content exists.
|
|
61
|
+
async patch(content, query) {
|
|
62
|
+
const denied = await enforceWriteAuth(this, content);
|
|
63
|
+
if (denied)
|
|
64
|
+
return denied;
|
|
65
|
+
const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
|
|
66
|
+
if (denial)
|
|
67
|
+
return denial;
|
|
68
|
+
// Fail-closed, same as Memory's stored-state read: a throw aborts the
|
|
69
|
+
// write; missing/unreadable stored state cannot authorize a PATCH that
|
|
70
|
+
// typically omits agentId (that used to skip the content-provenance match).
|
|
71
|
+
const existing = await super.get();
|
|
72
|
+
if (!existing || typeof existing !== "object" || existing instanceof Response) {
|
|
73
|
+
return new Response(JSON.stringify({ error: "soul_stored_state_unavailable" }), {
|
|
74
|
+
status: 403,
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const learnedDenied = await refuseSoulWriteContent({ ...existing, ...content });
|
|
79
|
+
if (learnedDenied)
|
|
80
|
+
return learnedDenied;
|
|
81
|
+
return super.patch(content, query);
|
|
82
|
+
}
|
|
59
83
|
async put(content, context) {
|
|
60
84
|
const denied = await enforceWriteAuth(this, content);
|
|
61
85
|
if (denied)
|
|
62
86
|
return denied;
|
|
87
|
+
const learnedDenied = await refuseSoulWriteContent(content);
|
|
88
|
+
if (learnedDenied)
|
|
89
|
+
return learnedDenied;
|
|
90
|
+
const ownerDenial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
|
|
91
|
+
if (ownerDenial)
|
|
92
|
+
return ownerDenial;
|
|
63
93
|
content.updatedAt = new Date().toISOString();
|
|
64
94
|
// Write-time originatorInstanceId stamp — see post() above / Memory.ts's
|
|
65
95
|
// stampOriginatorInstanceId doc. No-op if already set.
|
|
@@ -68,4 +98,10 @@ export class Soul extends databases.flair.Soul {
|
|
|
68
98
|
}
|
|
69
99
|
return super.put(content, context);
|
|
70
100
|
}
|
|
101
|
+
async delete(id) {
|
|
102
|
+
const { denied } = await authorizeSoulWrite(this.getContext?.());
|
|
103
|
+
if (denied)
|
|
104
|
+
return denied;
|
|
105
|
+
return super.delete(id);
|
|
106
|
+
}
|
|
71
107
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { databases } from "harper";
|
|
11
11
|
import { resolveAgentAuth } from "./agent-auth.js";
|
|
12
|
+
import { guardOwnerFieldImmutable } from "./owner-field-guard.js";
|
|
12
13
|
import { invalidEntitiesResponse } from "./entity-vocab.js";
|
|
13
14
|
import { makeAuthGate, makeReadScope, makeByIdReadGate, makeScopedSearch, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
|
|
14
15
|
import { RECORD_TYPES } from "./record-types.js";
|
|
@@ -115,7 +116,18 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
115
116
|
return entitiesError;
|
|
116
117
|
return super.post(content);
|
|
117
118
|
}
|
|
119
|
+
// PATCH routes past put(), so agentId immutability is enforced on both verbs
|
|
120
|
+
// via the one shared delegate.
|
|
121
|
+
async patch(content, query) {
|
|
122
|
+
const denial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
|
|
123
|
+
if (denial)
|
|
124
|
+
return denial;
|
|
125
|
+
return super.patch(content, query);
|
|
126
|
+
}
|
|
118
127
|
async put(content) {
|
|
128
|
+
const __ownerDenial = await guardOwnerFieldImmutable(this, () => super.get(), content, "agentId");
|
|
129
|
+
if (__ownerDenial)
|
|
130
|
+
return __ownerDenial;
|
|
119
131
|
const auth = await this._auth();
|
|
120
132
|
if (auth.kind === "anonymous")
|
|
121
133
|
return UNAUTH();
|
|
@@ -444,6 +444,11 @@ server.http(async (request, nextLayer) => {
|
|
|
444
444
|
(method === "POST" || method === "PUT" || method === "PATCH")) {
|
|
445
445
|
if (!request.tpsAgentIsAdmin) {
|
|
446
446
|
try {
|
|
447
|
+
// NOTE: dead code — Harper's middleware Request has no parsed body
|
|
448
|
+
// (no .clone()/.json()), so this body-check never fires. Owner/
|
|
449
|
+
// attribution enforcement lives in the resource layer (record-owner-
|
|
450
|
+
// guard + owner-field-guard). Kept for a separate cleanup PR — not
|
|
451
|
+
// live coverage.
|
|
447
452
|
const clone = request.clone();
|
|
448
453
|
const body = await clone.json();
|
|
449
454
|
if (body?.authorId && body.authorId !== agentId) {
|
|
@@ -478,6 +483,11 @@ server.http(async (request, nextLayer) => {
|
|
|
478
483
|
(method === "POST" || method === "PUT" || method === "PATCH")) {
|
|
479
484
|
if (!request.tpsAgentIsAdmin) {
|
|
480
485
|
try {
|
|
486
|
+
// NOTE: dead code — Harper's middleware Request has no parsed body
|
|
487
|
+
// (no .clone()/.json()), so this body-check never fires. Owner/
|
|
488
|
+
// attribution enforcement lives in the resource layer (record-owner-
|
|
489
|
+
// guard + owner-field-guard). Kept for a separate cleanup PR — not
|
|
490
|
+
// live coverage.
|
|
481
491
|
const clone = request.clone();
|
|
482
492
|
const body = await clone.json();
|
|
483
493
|
if (body?.agentId && body.agentId !== agentId) {
|
|
@@ -546,6 +556,11 @@ server.http(async (request, nextLayer) => {
|
|
|
546
556
|
if (method !== "DELETE") {
|
|
547
557
|
let bodyAgentId = null;
|
|
548
558
|
try {
|
|
559
|
+
// NOTE: dead code — Harper's middleware Request has no parsed body
|
|
560
|
+
// (no .clone()/.json()), so this body-check never fires. Owner/
|
|
561
|
+
// attribution enforcement lives in the resource layer (record-owner-
|
|
562
|
+
// guard + owner-field-guard). Kept for a separate cleanup PR — not
|
|
563
|
+
// live coverage.
|
|
549
564
|
const clone = request.clone();
|
|
550
565
|
const body = await clone.json();
|
|
551
566
|
bodyAgentId = body?.agentId ?? null;
|
|
@@ -562,50 +577,8 @@ server.http(async (request, nextLayer) => {
|
|
|
562
577
|
// one condition enforced in two places is how the two drift apart.
|
|
563
578
|
}
|
|
564
579
|
}
|
|
565
|
-
// Memory
|
|
566
|
-
|
|
567
|
-
(method === "PUT" || method === "POST" || method === "PATCH")) {
|
|
568
|
-
if (!request.tpsAgentIsAdmin) {
|
|
569
|
-
try {
|
|
570
|
-
const clone = request.clone();
|
|
571
|
-
const body = await clone.json();
|
|
572
|
-
const setsApproved = body?.promotionStatus === "approved";
|
|
573
|
-
const setsPermanent = body?.durability === "permanent";
|
|
574
|
-
const setsArchived = body?.archived === true;
|
|
575
|
-
if (setsApproved || setsPermanent || setsArchived) {
|
|
576
|
-
return new Response(JSON.stringify({
|
|
577
|
-
error: "forbidden: only admins can approve promotions, set permanent durability, or archive memories"
|
|
578
|
-
}), { status: 403 });
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
catch { }
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
// Memory DELETE: permanent memories are admin-only to purge.
|
|
585
|
-
//
|
|
586
|
-
// The OWNERSHIP half of this guard — which was the model the shared
|
|
587
|
-
// record-ownership rule above was built from, and the only one in this file
|
|
588
|
-
// that already covered PATCH — now lives there and covers every table. What
|
|
589
|
-
// remains here is the part that is NOT about ownership: durability. An agent
|
|
590
|
-
// owning a permanent memory still may not purge it.
|
|
591
|
-
if (((url.pathname === "/Memory" || url.pathname.startsWith("/Memory/") || url.pathname === "/memory" || url.pathname.startsWith("/memory/"))) &&
|
|
592
|
-
method === "DELETE") {
|
|
593
|
-
if (!request.tpsAgentIsAdmin) {
|
|
594
|
-
try {
|
|
595
|
-
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
596
|
-
const memId = pathParts[1] ? decodeURIComponent(pathParts[1]) : null;
|
|
597
|
-
if (memId) {
|
|
598
|
-
const record = await databases.flair.Memory.get(memId);
|
|
599
|
-
if (record?.durability === "permanent") {
|
|
600
|
-
return new Response(JSON.stringify({
|
|
601
|
-
error: "forbidden: only admins can purge permanent memories"
|
|
602
|
-
}), { status: 403 });
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
catch { }
|
|
607
|
-
}
|
|
608
|
-
}
|
|
580
|
+
// Memory workflow-field provenance is enforced on parsed resource writes.
|
|
581
|
+
// Memory deletion uses the shared stored-owner rule for every tier.
|
|
609
582
|
}
|
|
610
583
|
// ── WorkspaceState read guard: agent-scoped reads ───────────────────────────
|
|
611
584
|
if (method === "GET" && !request.tpsAgentIsAdmin) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Workflow verdicts can only be stamped by their trusted raw-table paths.
|
|
2
|
+
* Keep unchanged echoes compatible with full-row clients, and preserve omitted
|
|
3
|
+
* stamps on PUT; omission must not erase a verdict. A content change drops the
|
|
4
|
+
* stamp: the verdict is bound to the reviewed text. No admin/body bypass. */
|
|
5
|
+
export const AUTHORITY_FIELDS = {
|
|
6
|
+
Memory: ["promotionStatus", "promotedAt", "promotedBy"],
|
|
7
|
+
};
|
|
8
|
+
export async function guardAuthorityFields(getExisting, content, table) {
|
|
9
|
+
const existing = await getExisting();
|
|
10
|
+
for (const field of AUTHORITY_FIELDS[table]) {
|
|
11
|
+
if (Object.hasOwn(content, field) && content[field] !== existing?.[field]) {
|
|
12
|
+
return new Response(JSON.stringify({ error: `forbidden: ${field} is set by the promotion workflow` }), {
|
|
13
|
+
status: 403, headers: { "content-type": "application/json" },
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// The stamp means this specific content was reviewed. Echo-tolerance and
|
|
18
|
+
// omitted-field restore apply only when the body does not change `content`.
|
|
19
|
+
// Null the columns (do not merely delete) so PATCH and PUT both drop them.
|
|
20
|
+
if (existing && Object.hasOwn(content, "content") && content.content !== existing.content) {
|
|
21
|
+
for (const field of AUTHORITY_FIELDS[table])
|
|
22
|
+
content[field] = null;
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
for (const field of AUTHORITY_FIELDS[table]) {
|
|
26
|
+
if (existing && Object.hasOwn(existing, field))
|
|
27
|
+
content[field] = existing[field];
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
/** Drop workflow stamps from a request-origin raw write. Unconditional: even
|
|
32
|
+
* stamps the guard would restore onto an omitted-field update must not land
|
|
33
|
+
* through ingest paths that are not a promotion-stamp site. */
|
|
34
|
+
export function stripAuthorityFields(record, table) {
|
|
35
|
+
for (const field of AUTHORITY_FIELDS[table])
|
|
36
|
+
delete record[field];
|
|
37
|
+
}
|
|
@@ -127,7 +127,7 @@ function record(ev) {
|
|
|
127
127
|
}
|
|
128
128
|
/** Read-your-write hook: call immediately after a committed Memory write that
|
|
129
129
|
* changed content or any scope/temporal attribute. Safe to call for writes
|
|
130
|
-
* that changed neither (
|
|
130
|
+
* that changed neither (an unchanged indexed projection is ignored). */
|
|
131
131
|
export function noteMemoryUpsert(row) {
|
|
132
132
|
const r = project(row);
|
|
133
133
|
if (r)
|
|
@@ -89,7 +89,21 @@ export const SUPPORTED_SCOPE_ATTRS = [
|
|
|
89
89
|
export const TEMPORAL_ATTRS = ["createdAt", "expiresAt", "validFrom", "validTo"];
|
|
90
90
|
/** The `select` a corpus scan needs in order to feed this index. */
|
|
91
91
|
export const INDEX_SELECT = ["id", "content", ...SUPPORTED_SCOPE_ATTRS, ...TEMPORAL_ATTRS];
|
|
92
|
+
// Bound exact-body retention independently of corpus size. Oversized bodies
|
|
93
|
+
// and evicted entries use the existing re-tokenization path, never a hash.
|
|
94
|
+
const MAX_CACHED_BODIES = 1024;
|
|
95
|
+
const MAX_CACHED_BODY_CHARS = 512 * 1024;
|
|
96
|
+
const INDEX_META_ATTRS = [...SUPPORTED_SCOPE_ATTRS, ...TEMPORAL_ATTRS];
|
|
92
97
|
const SUPPORTED_ATTR_SET = new Set(SUPPORTED_SCOPE_ATTRS);
|
|
98
|
+
// Indexed metadata consists of scalars and arrays of scalars. Unknown object
|
|
99
|
+
// shapes never qualify for the fast path. Arrays are copied on admission so a
|
|
100
|
+
// caller reusing and mutating its record cannot change the comparison snapshot.
|
|
101
|
+
function sameIndexValue(a, b) {
|
|
102
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
103
|
+
return a.length === b.length && a.every((v, i) => (v === null || typeof v !== "object") && v === b[i]);
|
|
104
|
+
}
|
|
105
|
+
return (a === null || typeof a !== "object") && a === b;
|
|
106
|
+
}
|
|
93
107
|
function emptyPosting() {
|
|
94
108
|
return { slots: new Int32Array(4), tfs: new Int32Array(4), len: 0 };
|
|
95
109
|
}
|
|
@@ -181,12 +195,33 @@ export class Bm25Index {
|
|
|
181
195
|
sweptTo = 0;
|
|
182
196
|
/** Slots retired by the sweep — removed from the aggregates. */
|
|
183
197
|
retired = new Set();
|
|
198
|
+
cachedBodies = new Map();
|
|
199
|
+
cachedBodyChars = 0;
|
|
200
|
+
forgetBody(id) {
|
|
201
|
+
const content = this.cachedBodies.get(id);
|
|
202
|
+
if (content === undefined)
|
|
203
|
+
return;
|
|
204
|
+
this.cachedBodyChars -= content.length;
|
|
205
|
+
this.cachedBodies.delete(id);
|
|
206
|
+
}
|
|
207
|
+
rememberBody(id, content) {
|
|
208
|
+
this.forgetBody(id);
|
|
209
|
+
if (content.length > MAX_CACHED_BODY_CHARS)
|
|
210
|
+
return;
|
|
211
|
+
while (this.cachedBodies.size >= MAX_CACHED_BODIES || this.cachedBodyChars + content.length > MAX_CACHED_BODY_CHARS) {
|
|
212
|
+
this.forgetBody(this.cachedBodies.keys().next().value);
|
|
213
|
+
}
|
|
214
|
+
this.cachedBodies.set(id, content);
|
|
215
|
+
this.cachedBodyChars += content.length;
|
|
216
|
+
}
|
|
184
217
|
get size() { return this.slotOf.size; }
|
|
185
218
|
/** Live postings, for the memory-footprint assertions in the tests. */
|
|
186
219
|
get postingCount() { return this.totalPostings - this.deadPostings; }
|
|
187
220
|
get termCount() { return this.postings.size; }
|
|
188
221
|
has(id) { return this.slotOf.has(id); }
|
|
189
222
|
clear() {
|
|
223
|
+
this.cachedBodies.clear();
|
|
224
|
+
this.cachedBodyChars = 0;
|
|
190
225
|
this.slots = [];
|
|
191
226
|
this.slotOf.clear();
|
|
192
227
|
this.freeSlots = [];
|
|
@@ -201,30 +236,32 @@ export class Bm25Index {
|
|
|
201
236
|
this.retired.clear();
|
|
202
237
|
}
|
|
203
238
|
// ─── Maintenance ──────────────────────────────────────────────────────────
|
|
204
|
-
/**
|
|
205
|
-
*
|
|
206
|
-
* the postings": an upsert always tombstones the old slot and appends a new
|
|
207
|
-
* one. Detecting an unchanged body would need a content fingerprint, and a
|
|
208
|
-
* fingerprint collision is a silently-wrong lexical index — the one failure
|
|
209
|
-
* mode this index may not have. The cost is a dead posting run per update,
|
|
210
|
-
* reclaimed by `compact()` below at O(1) amortized.
|
|
211
|
-
*/
|
|
239
|
+
/** Ignore writes whose complete indexed projection is unchanged. Relevant
|
|
240
|
+
* changes still replace the slot, preserving aggregate and expiry handling. */
|
|
212
241
|
upsert(record) {
|
|
213
242
|
const id = record?.id;
|
|
214
243
|
if (typeof id !== "string" || id.length === 0)
|
|
215
244
|
return;
|
|
245
|
+
const content = record.content || "";
|
|
246
|
+
const previousSlot = this.slotOf.get(id);
|
|
247
|
+
const previous = previousSlot === undefined ? null : this.slots[previousSlot];
|
|
248
|
+
if (previous && this.cachedBodies.get(id) === content &&
|
|
249
|
+
INDEX_META_ATTRS.every((attr) => (attr in previous.meta) === (attr in record) && sameIndexValue(previous.meta[attr], record[attr]))) {
|
|
250
|
+
this.rememberBody(id, content);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
216
253
|
this.remove(id);
|
|
217
|
-
const tokens = tokenize(
|
|
254
|
+
const tokens = tokenize(content);
|
|
218
255
|
const tf = new Map();
|
|
219
256
|
for (const t of tokens)
|
|
220
257
|
tf.set(t, (tf.get(t) || 0) + 1);
|
|
221
258
|
const meta = { id };
|
|
222
259
|
for (const attr of SUPPORTED_SCOPE_ATTRS)
|
|
223
260
|
if (attr in record)
|
|
224
|
-
meta[attr] = record[attr];
|
|
261
|
+
meta[attr] = Array.isArray(record[attr]) ? record[attr].slice() : record[attr];
|
|
225
262
|
for (const attr of TEMPORAL_ATTRS)
|
|
226
263
|
if (attr in record)
|
|
227
|
-
meta[attr] = record[attr];
|
|
264
|
+
meta[attr] = Array.isArray(record[attr]) ? record[attr].slice() : record[attr];
|
|
228
265
|
const slot = this.freeSlots.length > 0 ? this.freeSlots.pop() : this.slots.length;
|
|
229
266
|
const entry = {
|
|
230
267
|
id,
|
|
@@ -236,6 +273,7 @@ export class Bm25Index {
|
|
|
236
273
|
};
|
|
237
274
|
this.slots[slot] = entry;
|
|
238
275
|
this.slotOf.set(id, slot);
|
|
276
|
+
this.rememberBody(id, content);
|
|
239
277
|
for (const [term, count] of tf) {
|
|
240
278
|
let p = this.postings.get(term);
|
|
241
279
|
if (!p) {
|
|
@@ -262,6 +300,7 @@ export class Bm25Index {
|
|
|
262
300
|
return;
|
|
263
301
|
const entry = this.slots[slot];
|
|
264
302
|
this.slotOf.delete(id);
|
|
303
|
+
this.forgetBody(id);
|
|
265
304
|
this.slots[slot] = null;
|
|
266
305
|
if (entry) {
|
|
267
306
|
if (!this.retired.delete(slot))
|