12 KiB
PR3.5 Recon — frame.source server read/write path (1-field projection)
Goal: add source to the recentMemories + recentDecisions items returned by the
workspace-state / workspace-context API so the FE can render a ⬡ provenance pill on Chat +
Workspace. This doc maps the exact write site, the exact read/SELECT/projection sites, the
type-contract change, and the column's value vocabulary.
Repo root: D:/Projects/waggle-os. All line numbers verified 2026-06-16.
0. TL;DR — what to change
The ⬡ provenance pill on the Workspace Briefing (Chat + Workspace Overview both render
<WorkspaceBriefing> fed by GET /api/workspaces/:id/context) is unlocked by one route +
one type change:
packages/server/src/local/routes/workspaces.ts— addsourceto the two SELECTs (lines 393-400 and 409-417) and to their two.map()projections (lines 402-406 and 419-429).apps/web/src/lib/types.ts— widenWorkspaceContext.recentMemories/recentDecisionsitem shapes (lines 248-249) with an optionalsource?: string.
That is the minimum. There is a SECOND, parallel context builder in
packages/server/src/local/routes/workspace-context.ts (the WorkspaceNowBlock /
buildWorkspaceState path) that feeds the system prompt, NOT the FE pill — see §4 for why
it is out of scope, and the one ambiguity it raises.
1. WRITE path — where frame.source is set
1a. Schema DDL (the column itself)
packages/hive-mind-core/src/mind/schema.ts:56-57
source TEXT NOT NULL DEFAULT 'user_stated'
CHECK (source IN ('user_stated', 'tool_verified', 'agent_inferred', 'import', 'system')),
Column lives on memory_frames (DDL CREATE TABLE ... memory_frames at schema.ts:47).
Confirmed: the column name is source (not frame_source / provenance).
1b. Where rows are written with an explicit source
-
Template starter seeding —
packages/server/src/local/routes/workspaces.ts:290-291:INSERT INTO memory_frames (frame_type, gop_id, t, content, importance, source) VALUES ('I', ?, ?, ?, 'normal', 'system')i.e. workspace template starter memories are written with
source = 'system'. -
General frame writes go through
FrameStoreinpackages/hive-mind-core/src/mind/frames.ts—createIFrame/createPFrame(frames.ts:74,119) defaultsource: FrameSource = 'user_stated'. -
MCP
save_memorymaps an incomingsourcearg, defaulting to'agent_inferred'(packages/memory-mcp/src/tools/memory.ts:35,packages/hive-mind-mcp-server/src/tools/memory.ts:34).
So in practice persisted rows carry one of the five DB-CHECK values (see §3).
2. READ path — the SELECT + projection that feeds the FE (THE CHANGE SITE)
Route: GET /api/workspaces/:id/context —
packages/server/src/local/routes/workspaces.ts:364 (handler).
This is the response consumed by <WorkspaceBriefing> (apps/web), which renders both the
"Key memories" list and the "Recent decisions" list on Chat + Workspace.
2a. recentMemories — SELECT at workspaces.ts:393-400, projection at 402-406
BEFORE (SELECT, lines 393-400):
const frames = raw.prepare(
`SELECT content, importance, created_at FROM memory_frames
WHERE importance != 'deprecated' AND importance != 'temporary'
ORDER BY CASE importance
WHEN 'critical' THEN 1 WHEN 'important' THEN 2
WHEN 'normal' THEN 3 ELSE 4 END,
id DESC LIMIT 8`
).all() as Array<{ content: string; importance: string; created_at: string }>;
AFTER:
const frames = raw.prepare(
`SELECT content, importance, source, created_at FROM memory_frames
WHERE importance != 'deprecated' AND importance != 'temporary'
ORDER BY CASE importance
WHEN 'critical' THEN 1 WHEN 'important' THEN 2
WHEN 'normal' THEN 3 ELSE 4 END,
id DESC LIMIT 8`
).all() as Array<{ content: string; importance: string; source: string; created_at: string }>;
BEFORE (projection, lines 402-406):
recentMemories = frames.map(f => ({
content: f.content.slice(0, 200),
importance: f.importance,
date: f.created_at?.slice(0, 10) ?? 'unknown',
}));
AFTER:
recentMemories = frames.map(f => ({
content: f.content.slice(0, 200),
importance: f.importance,
source: f.source,
date: f.created_at?.slice(0, 10) ?? 'unknown',
}));
The local
let recentMemoriesis declared atworkspaces.ts:376asArray<{ content: string; importance: string; date: string }>— addsource: string;there too (or the.map()widening will type-error against the narrower local).
2b. recentDecisions — SELECT at workspaces.ts:409-417, projection at 419-429
BEFORE (SELECT, lines 409-417):
const decisionFrames = raw.prepare(
`SELECT content, created_at FROM memory_frames
WHERE importance != 'deprecated' AND importance != 'temporary'
AND (content LIKE 'Decision%' OR content LIKE '%decided%'
OR content LIKE '%decision made%' OR content LIKE '%chose %'
OR content LIKE '%selected %' OR content LIKE '%agreed %'
OR importance = 'critical')
ORDER BY id DESC LIMIT 5`
).all() as Array<{ content: string; created_at: string }>;
AFTER:
const decisionFrames = raw.prepare(
`SELECT content, source, created_at FROM memory_frames
WHERE importance != 'deprecated' AND importance != 'temporary'
AND (content LIKE 'Decision%' OR content LIKE '%decided%'
OR content LIKE '%decision made%' OR content LIKE '%chose %'
OR content LIKE '%selected %' OR content LIKE '%agreed %'
OR importance = 'critical')
ORDER BY id DESC LIMIT 5`
).all() as Array<{ content: string; source: string; created_at: string }>;
BEFORE (projection, lines 419-429):
recentDecisions = decisionFrames.map(f => {
const firstLine = f.content.split('\n')[0];
const sentenceMatch = firstLine.match(/^(.+?\.\s)(?=[A-Z])/);
const text = sentenceMatch
? sentenceMatch[1].trim()
: (firstLine.length > 150 ? firstLine.slice(0, 147) + '...' : firstLine);
return {
content: text.replace(/\.\s*$/, ''),
date: f.created_at?.slice(0, 10) ?? 'unknown',
};
});
AFTER — add source: f.source, to the returned object:
return {
content: text.replace(/\.\s*$/, ''),
source: f.source,
date: f.created_at?.slice(0, 10) ?? 'unknown',
};
Same as above: the local
let recentDecisionsdeclared atworkspaces.ts:377asArray<{ content: string; date: string }>must gainsource: string;.
Response shape (unchanged structurally)
The handler returns an object whose recentMemories / recentDecisions fields are these two
arrays (assembled further down in the same handler, returned as part of the workspace-context
body). Adding source is purely additive — no existing consumer breaks.
3. TYPE-CONTRACT change
File: apps/web/src/lib/types.ts — interface WorkspaceContext, lines 248-249:
BEFORE:
recentDecisions?: Array<{ content: string; date: string }>;
recentMemories?: Array<{ content: string; importance: string; date: string }>;
AFTER:
recentDecisions?: Array<{ content: string; source?: string; date: string }>;
recentMemories?: Array<{ content: string; importance: string; source?: string; date: string }>;
- Keep
sourceoptional (source?:) so older sidecars (pre-PR3.5) that don't emit the field still typecheck and the FE degrades gracefully (no pill when absent). - This is the ONLY type file that needs to change for the pill.
WorkspaceContextis the FE mirror; there is no separate@waggle/sharedinterface for these inline item shapes (they're declared anonymously inline in bothworkspaces.tsandtypes.ts). - The FE consumer is
apps/web/src/components/os/WorkspaceBriefing.tsx—recentMemoriesrendered at lines 186-197 (importance badge at 189-193, add the ⬡ pill alongside it),recentDecisionsrendered at lines 164-178. No type change needed in the component; it readsm.content/m.importancetoday and would addm.source.
Note: the richer
@waggle/sharedMemoryinterface (packages/shared/src/types.ts:517, fieldsource: stringat:526, doc-comment "maps frommemory_frames.source") already models provenance — but that's the Memory Center view-model, NOT the workspace-context item shape. Do not route the pill throughMemory; the context items are their own inline type.
4. The PARALLEL builder (workspace-context.ts) — out of scope, but flagged
packages/server/src/local/routes/workspace-context.ts builds WorkspaceNowBlock
(recentDecisions: string[], type at :14-21) for system-prompt injection, via
buildWorkspaceState() in packages/server/src/local/workspace-state.ts:234. Its decision
SELECT is workspace-state.ts:86-94 (SELECT id, content, created_at) and the legacy inline
one is workspace-context.ts:362 (SELECT content). Its items are typed StateItem
(workspace-state.ts:30-36) whose source field is a StateSource = 'memory'|'session'|'awareness' — that is a DIFFERENT source axis (where in the substrate the
item came from), not the frame.source provenance class.
Decision: the FE pill is fed by §2 (workspaces.ts /context route → WorkspaceContext
→ WorkspaceBriefing), so PR3.5's 1-field projection only needs §2 + §3. The
workspace-state.ts / WorkspaceNowBlock path does not surface to the pill and can be left
untouched. If a future task wants frame-provenance in the system prompt too, that's a separate,
larger change (it would collide with the existing StateItem.source name).
home.ts (GET /api/home/briefing) consumes buildWorkspaceState().recentDecisions[0].content
(home.ts:314-315) only — it reads .content, never .source, so it is unaffected.
5. SOURCE-column value vocabulary
DB CHECK constraint (authoritative for persisted rows) —
schema.ts:57:
| value | meaning |
|---|---|
user_stated |
user said it directly (FrameStore default) |
tool_verified |
confirmed by a tool execution |
agent_inferred |
agent inferred it (MCP save_memory default) |
import |
brought in via harvest/import |
system |
system-seeded (e.g. workspace template starter memory — workspaces.ts:291) |
TS FrameSource union is WIDER than the DB CHECK —
packages/hive-mind-core/src/mind/frames.ts:25:
export type FrameSource = 'user_stated' | 'tool_verified' | 'agent_inferred'
| 'import' | 'system' | 'personal' | 'workspace' | 'team_sync';
The extra three (personal / workspace / team_sync) are application-level labels that the
DB CHECK does not allow, so a constrained INSERT with one of them would fail — persisted
rows can only ever hold the 5 CHECK values (documented drift:
docs/backend-map/sections/02a-data-model-memory.md:109 and .../05b-subsystem-memory.md:318).
FE pill mapping guidance: the pill should map the 5 real values to friendly labels/icons
(e.g. user_stated→"you", tool_verified→"verified", agent_inferred→"agent",
import→"imported", system→"system"). Treat anything else as a graceful fallback. Because
the projection returns the raw string, the FE owns the label map (do not hardcode it in the
route).
6. Honest ambiguities / risks
- Two SELECTs, one new column each — straightforward, additive. The only typecheck trap is
the narrower
let recentMemories/let recentDecisionsdeclarations atworkspaces.ts:376-377; widen those too ortscfails. (Reminder per CLAUDE.md §2:npm run buildtypechecksapps/webonly — runnpx tsc --noEmit --project packages/server/tsconfig.jsonto catch a server-route type error, since the sidecar runs viatsxtranspile-only.) - The "source" name is overloaded —
frame.source(provenance: 5 CHECK values) vsStateItem.source(memory|session|awareness, where-it-came-from) vsWorkspaceContextinline items (currently nosource). Make sure the pill consumes the PROVENANCE one from §2, not theStateItemone. - Decision frames may carry any of the 5 sources — the decision SELECT (
workspaces.ts:409) filters bycontent LIKE/importance='critical', not bysource, so a "decision" can beagent_inferredoruser_statedetc. That's fine for a pill; just don't assume decisions are alwayssystem. - No migration / no DDL change needed —
sourcealready exists on every DB (NOT NULL DEFAULT). The projection is read-only over an existing column. Zero data-backfill risk.