Spec scope: extend concept-db so activities can create concept-db records (concepts, edges, usage, sequences, signature upserts) by resolving a write-shaped pointer against POST /v2/impulses/resolve, the same way activity-api already exposes its learning-loop writes. Make the resolve contract symmetric for read and write — but only by adding shapes, not by changing the contract surface itself.
This spec also gives concept-db a place to put impulses. Once the vessel exposes write resolvers, the things they emit (audit records, query-result snapshots, metrics) are themselves impulses in the universal-data sense and need somewhere to live. In the shared learning-loop database that place is the impulse table activity-api owns, which concept-db reaches under its own tenancy — see "concept-db and the impulse table" for why that is a consumer relationship and what happens when a vessel forgets it.
What ships. Both halves. activity-api serves twenty *_write shapes plus
three destructive shapes (activityTemplate_update,
activityTemplate_deprecate, activityExecutionTrace_delete) at
POST /v2/impulses/resolve. concept-db serves seven write shapes, all
advertised in repos/concept-db/src/config.ts and dispatched in
repos/concept-db/src/routes/impulses.ts:
| Write shape | What it writes |
|---|---|
concept_write |
a concept via the from-source path |
concept_create_write |
a concept from an explicit request body |
conceptLink_write |
an edge, EMA-upserting its weight |
conceptSignatureUpsert_write |
a concept keyed on an impulse signature, idempotently |
conceptUsage_write |
a usage record against a concept |
conceptSequence_write |
a concept sequence |
conceptCreditDecontaminate_write |
a credit-repair sweep over concepts whose usage rows carry synthetic or unbound trace ids |
The first six emit a conceptUpkeepAuditLog impulse and return its id under
metadata.auditImpulseId; conceptCreditDecontaminate_write does not — it
returns a conceptCreditDecontaminationReport body and defaults to a dry run
unless the pointer sets dry_run: false. Audit rows land in the impulse
table, which concept-db declares only defensively in
repos/concept-db/sql/core/003-impulse-table.surql — activity-api owns it —
while the insert / select / expire / prune helpers live in
repos/concept-db/src/resolvers/impulse.ts.
Two shapes named below as design remain design: concept_update_write and
conceptUpkeepTrigger_write have no dispatch case.
The foundation doc (docs/architecture/IMPULSE_ACTIVITY_FOUNDATION.md) treats impulses as universal data and resolvers as the access layer. In practice the contract is asymmetric:
-
Read is declarative. Activities advertise an impulse with
pointer.type = "<shape>", the executor calls the vessel's advertisedresolve_endpoint(default/v2/impulses/resolve) using the advertisedresolve_request_format, and gets{content, metadata}back. See the read cases inrepos/concept-db/src/routes/impulses.ts. A reader of an activity template can see exactly what data the activity reads. -
Write, before this spec, was imperative. Activities called the vessel's MCP tool registry (or a hand-rolled
bashtask withcurl) to create remote state. Thelearn-impulse-relationshipsactivity is the worked case: it upserted signature concepts and linked edges through MCP tool invocations, and shelled out tocurlagainst the upkeep trigger endpoint. A reader of that template had to grep the MCP tool registry to know what state mutations it performed.
The asymmetry has three concrete costs:
-
Trace integrity. Every read goes through one code path (
POST /v2/impulses/resolve), so the per-resolver trace (execution.impulse_resolutions[]) gets full coverage. Writes via MCP tool calls or curl shells produce a heterogeneous mix of trace fragments — some captured by the MCP tool resolver, some never captured at all. The learning loop can't reason about write outcomes the same way it reasons about reads. -
No advertised contract. Discovery (
VesselCapabilityinpackages/vessel-discovery-client/src/types.ts) lets a vessel advertise the shapes it resolves and how to call its resolve endpoint. There is no parallel for "shapes this vessel will create on your behalf". Activities have to hardcode tool names, REST paths, or MCP tool registries. -
Templates couple to endpoint paths. A template that hardcodes an upkeep-trigger URL breaks when the endpoint is renamed or the responsibility moves to a different vessel.
Activity-api solved this for its own write surface first, exposing its write and destructive shapes through the same POST /v2/impulses/resolve router. See docs/impulse-types/LEARNING_LOOP_WRITE_RESOLVERS.md. This spec applies the same pattern to concept-db.
A second observation, downstream of the first: concept-db's resolvers — both the read shapes and the write shapes — produce data that is itself impulse-shaped. Representations of concepts, query results from relatedConcepts, signature upsert decisions, audit trails of destructive operations, co-occurrence metrics: all of these are universal-data outputs with metadata, summary, and resolved content. If they were ephemeral in-memory structures we would lose them every request. concept-db needs the impulse table, reached under its own org scoping so tenancy holds regardless of which vessel wrote the row.
-
Don't break the read path.
POST /v2/impulses/resolveis the canonical resolve entrypoint and must keep working as-is. New write shapes are additive cases in the dispatch switch. -
Mirror the activity-api precedent. No new endpoint, no new request envelope, no contract-field changes — just more shapes. Anyone who already speaks the resolve contract should be able to do writes by emitting a different
pointer.type. -
Keep MCP tools. They're the human-driver and IDE-driver path (concept-db is wired into metabob-mcp). Templates and ad-hoc human use both have to keep working. The choice is between coexistence and deprecation; we pick coexistence — see "Migration path".
-
Audit destructive operations. concept-db has destructive surface (
concept_linkoverwrites edge weight via EMA,conceptPATCH, future deletes) that the activity-api precedent calls out for audit. The audit destination is theimpulsetable. -
Multi-tenant isolation must hold. Every write resolver threads
org_idfrom JWT auth into the underlying resolver function. concept-db's resolver layer (repos/concept-db/src/resolvers/concept.tsand its siblings) already takesorgIdandjwtToken, so this is a passthrough — the dispatch case mirrors the REST handler. Threadingorg_idis what makes the row land under the caller's tenancy; the clauses each table actually enforces differ, and are given under "PERMISSIONS" below rather than restated here. -
Vessels own their data. A vessel writes its impulses into the table it can reach under its own tenancy rules, never into another vessel's tables under another vessel's credentials.
What activity-api shipped (and what this spec copies almost verbatim):
In repos/activity-api/src/routes/impulses.ts each write case looks like:
case 'activityExecutionTrace_write': {
const writePointer = pointer as typeof pointer & { traceData?: unknown };
if (!writePointer.traceData) {
return c.json({ success: false, error: 'traceData required for activityExecutionTrace_write' }, 400);
}
const delegated = await delegateWriteToRouter(c, executionTracesRouter, '/', writePointer.traceData);
return c.json(buildWriteResolverResponse('activityExecutionTrace_write', delegated, 'execution trace stored'), …);
}delegateWriteToRouter constructs an internal Request against the existing REST sub-router, forwarding the caller's auth headers verbatim. It reuses the REST handler unchanged — no logic duplication.
buildWriteResolverResponse wraps the delegated response in the impulse-resolve envelope. Successful response:
The _result suffix on metadata.shape is how a client distinguishes a write-ack from a read payload — load-bearing.
requireAuthenticated(c) early-rejects anonymous callers with 401 for destructive resolvers. For non-destructive _write it relies on the underlying REST handler's auth, which means a vessel can choose per-shape policy by mounting some shapes behind requireAuthenticated and others not.
Destructive operations in activity-api call emitUpkeepAudit(...), which inserts an impulse row with shape: 'upkeepAuditLog' and a pointer field carrying:
{
operation: 'delete' | 'update' | 'deprecate',
target_table: string,
target_ids: string[],
filter_used: Record<string, unknown>,
dry_run: boolean,
count: number,
performed_by: string,
org_id: string,
reason?: string,
diff?: Record<string, unknown>,
performed_at: ISO timestamp,
}Non-blocking — a failed audit does not roll back the operation. The audit returns the new impulse id to be embedded in the response under auditImpulseId. The impulse table is activity-api's, defined in repos/activity-api/sql/schemas/020-paradigm-core-tables.surql and re-declared for root access by repos/activity-api/sql/migrations/071-fix-paradigm-tables-root-access.surql. concept-db mirrors the pattern against the same table name — see "concept-db and the impulse table" below for why that is a consumer relationship and not a second definition.
docs/impulse-types/LEARNING_LOOP_WRITE_RESOLVERS.md is the user-facing contract: required pointer fields per shape, response envelope, auth notes, when-to-use guidance. concept-db should ship a sibling doc.
- No edits to
packages/vessel-discovery-client/src/types.ts—ResolveRequestFormatis still"pointer" | "mcp-tool", no third value introduced. - No edits to the
VesselCapabilityshape. - No new endpoint paths.
- No client changes — the existing generic resolver already speaks the impulse-resolve contract; new shapes flow through unchanged.
The pattern is purely additive at the vessel level.
Adding ResolveRequestFormat = "pointer" | "mcp-tool" | "write" and parallel arrays in VesselCapability (e.g. read_shapes, write_shapes).
Why not. resolve_request_format describes the wire format of the request body. Read-vs-write is semantics of a particular shape, not a wire-format axis. The activity-api precedent demonstrates that the existing "pointer" format already handles writes fine — {pointer: {type: "foo_write", barData: {...}}} is a valid pointer envelope. Splitting on read/write at the contract level would require parallel splits everywhere downstream for zero practical benefit.
Adopt the activity-api convention: a shape ending in _write is a write-shape; pointer carries its payload under a per-shape *Data field; response carries metadata.shape = "<shape>_result". Discovery treats write-shapes identically to read-shapes — they're listed in the vessel's shapes array and the dispatcher routes by name.
Why yes. This is what activity-api shipped and what this spec recommends. The contract is a single-axis "advertise a shape and dispatch on it"; "write" is a kind of shape, not a kind of contract. Composability with existing resolvers (pointer.type is still the single discriminator) and zero churn in vessel-discovery-client.
Same wire format on the way in ({pointer: {...}}), but instead of a name suffix, the resolver returns metadata.kind = "write" | "read".
Why not. This loses the static signal — a template author can't tell from grepping for _write what an activity mutates. Discovery query results can't be filtered to "write capabilities" without round-tripping each shape. The _write suffix is documentation as well as dispatch.
Concede that read = pointer, write = MCP tool, and document the asymmetry as design.
Why not. Activity-api already broke the symmetry the other way (writes via pointer resolution), so the asymmetry was never design — it was one vessel not having caught up. A template talking to both vessels would otherwise carry both idioms. Picking one idiom is the simplification.
Mirror the activity-api _write pattern in concept-db. No contract changes. *_write cases live in repos/concept-db/src/routes/impulses.ts. Each case validates the required payload field, calls the corresponding resolver function (the same one the REST handler in routes/concepts.ts calls), and wraps the result in the standard impulse-resolve envelope with metadata.shape = "<type>_result".
Each mutating shape emits a conceptUpkeepAuditLog-shaped impulse into the impulse table. Non-blocking emit.
Unlike activity-api, concept-db needs no delegateWriteToRouter-style internal fetch: its resolver functions are already plain async functions taking orgId and jwtToken, so the dispatch case calls them directly.
The discovery contract (packages/vessel-discovery-client/src/types.ts) does not change. concept-db's config.discovery.shapes in repos/concept-db/src/config.ts carries the write shapes alongside the read shapes. A client doing a discovery query for shape concept_create_write finds concept-db, calls the advertised resolve_endpoint with the advertised resolve_request_format = "pointer", and gets a write resolver back. Same plumbing as a read.
From the surface in repos/concept-db/src/routes/concepts.ts and src/tools/handler.ts:
| MCP tool / REST path | Write shape | Required pointer fields | Notes |
|---|---|---|---|
POST /concepts/from-source |
concept_write |
source_type, content (both strings) |
Unified from-source path. Optional summary, priority, budget, scope, public, project_id, metadata. |
POST /concepts / concept_create |
concept_create_write |
conceptData |
Body matches CreateConceptRequestSchema. |
POST /concepts/:id/link / concept_link |
conceptLink_write |
linkData (must include from_concept_id, to_concept_id, edge_type) |
EMA-upsert; high traffic from learning templates. |
POST /concepts/upsert-by-signature / concept_upsert_by_signature |
conceptSignatureUpsert_write |
pointer_type, shape |
Idempotent; the biggest call site, one call per signature pair. |
POST /concepts/:id/usage / concept_record_usage |
conceptUsage_write |
usageData (must include concept_id) |
Hot path for activity callbacks. |
POST /concepts/sequences / concept_sequence_record |
conceptSequence_write |
sequenceData |
Lower volume; converted for symmetry. |
| (no REST equivalent) | conceptCreditDecontaminate_write |
none; optional dry_run, min_loads |
Sweeps concepts at or above min_loads and rebuilds their credit counts from usage rows, discarding synthetic and unbound trace ids. Dry run unless dry_run: false. |
POST /concepts/:id/resolve |
— | — | Skip. Read-shaped despite being a POST. Already covered by the concept read shape. |
PATCH /concepts/:id |
— | — | Design only. Becomes concept_update_write (destructive) when needed; carries an updates whitelist. |
POST /upkeep/trigger |
— | — | Design only. conceptUpkeepTrigger_write would take an upkeepConfig. Destructive — requires audit plus admin scope. |
No change to the contract. The activity-api precedent demonstrates resolve_request_format = "pointer" is sufficient for both reads and writes.
A write is never anonymous:
- Writes require an authenticated caller, whatever
REQUIRE_AUTHsays. The resolve route checks the caller before any resolver runs. An unauthenticated caller may resolve only a read allowlist (UNAUTHENTICATED_READ_SHAPESinrepos/concept-db/src/routes/impulses.ts): shapes that perform no state-changing statement, not even a passive usage counter. A read whose form records usage (for exampleconceptwith aconcept_id) is outside the allowlist. Every*_writeshape and every unknown shape needs credentials. - A refusal is visible. It returns
401 AUTH_REQUIRED, naming the shape and the allowlist, and logs exactly one[auth-refused]line, so refusals can be counted rather than inferred. - Credentials are validated inside the fleet and fail closed. An API key is validated by identity-vessel. There is no fallback to a host outside the fleet. A key the validator rejects, or cannot be asked about (error, timeout, unreachable), grants no credentials: the caller is unauthenticated, so the write guard above refuses it.
org_idflows through the samegetJwtAuthFromContext(c)path the read resolvers use. Multi-tenant isolation is preserved because the resolver functions takeorgIdand use it in their queries.
The auth_scheme discovery field stays a single value advertised at the vessel level; finer-grained per-shape policy lives inside the dispatcher.
For each write shape:
Success (2xx):
{
"success": true,
"content": "<JSON.stringify(resolver-result)>",
"metadata": {
"shape": "<pointer.type>_result",
"summary": "human-readable one-liner"
}
}The content field is a JSON-stringified blob carrying whatever the underlying resolver returns — full Concept row for concept_create_write, {id, created} for conceptSignatureUpsert_write, Edge row for conceptLink_write, etc. Match what the existing REST handlers return.
Failure (4xx/5xx):
{
"success": false,
"error": "<message>"
}Status codes:
- 400 — missing required pointer field; payload Zod parse failure.
- 401 —
requireAuthis set and no JWT was forwarded. - 404 — referenced concept/edge does not exist.
- 500 — resolver error.
The reasoning, restated: concept-db's resolvers (read and write) emit data that is itself impulse-shaped — representations, query results, mappings, metrics, audit records. That data needs a place to live, and the vessel needs a story for how future activities reference it.
concept-db is a consumer of impulse, not its owner. It runs against the shared learning-loop database, where activity-api owns the table and holds the authoritative SCHEMAFULL definition with tenant-isolation PERMISSIONS. concept-db reads and writes rows through repos/concept-db/src/resolvers/impulse.ts; it does not define the table's shape.
This distinction is load-bearing. An unguarded DEFINE TABLE impulse SCHEMAFULL in concept-db's migration would overwrite activity-api's definition on every concept-db restart, stripping the tenant-isolation PERMISSIONS. Never write an unguarded DEFINE TABLE for a table another vessel owns in a shared database.
repos/concept-db/sql/core/003-impulse-table.surql is written entirely with IF NOT EXISTS, so in the shared deployment every statement is a no-op against activity-api's existing definition, and on a standalone concept-db database it creates a minimal permissive SCHEMALESS table the resolvers can still use. The field set it expects, matching the canonical impulse pattern:
| Field | Type | Notes |
|---|---|---|
id |
string |
Auto-generated. |
pointer |
object |
Free-form; shape-specific payload. |
shape |
string |
The shape this impulse advertises (e.g. conceptUpkeepAuditLog, relatedConceptsResult). |
summary |
option<string> |
Human-readable one-liner; what reasoners see before content load. |
content |
option<string> |
JSON-stringified payload, or null for pointer-only impulses. |
metadata |
option<object> |
Free-form; resolver tier, latency, cost, etc. |
org_id |
string |
Tenancy field; mandatory. |
project_id |
option<string> |
Project scope. |
created_at |
datetime |
DEFAULT time::now(). |
created_by_activity_id |
option<string> |
The activity execution that produced this impulse, if any. Useful for ribosome / replay. |
created_by_resolver_id |
option<string> |
The resolver that produced this impulse (e.g. concept_create_write, relatedConcepts). |
expires_at |
option<datetime> |
TTL marker for ephemeral impulses (cached query results). NULL = persistent. |
This is intended for both persistent records (audit logs, signature upsert decisions — set expires_at = NULL) and ephemeral records (cached query results from expensive read resolvers — set expires_at = time::now() + duration). A periodic cleanup task in the upkeep scheduler removes rows where expires_at < time::now().
The shared table's PERMISSIONS are activity-api's, defined alongside the table in repos/activity-api/sql/schemas/020-paradigm-core-tables.surql. They are tenant-scoped but not uniform across operations: select requires org_id = $auth.org_id plus a project-scope check, create requires only that the caller has an org at all ($auth.org_id != NONE), and update and delete require org_id = $auth.org_id plus either an admin role or ownership of the row. Read that file before writing anything that depends on the exact clause. concept-db's guarded fallback declares its own, simpler clauses — a floor so that a standalone database is not left permissionless, not a copy of activity-api's rules:
DEFINE TABLE IF NOT EXISTS impulse SCHEMALESS
PERMISSIONS
FOR select WHERE org_id = $auth.org_id
FOR create, update WHERE org_id = $auth.org_id
FOR delete WHERE org_id = $auth.org_id;Declared the same guarded way, so a shared deployment gains no duplicate indexes and no write amplification:
DEFINE INDEX IF NOT EXISTS idx_impulse_org ON impulse FIELDS org_id;
DEFINE INDEX IF NOT EXISTS idx_impulse_shape ON impulse FIELDS shape;
DEFINE INDEX IF NOT EXISTS idx_impulse_created ON impulse FIELDS created_at;
DEFINE INDEX IF NOT EXISTS idx_impulse_expires ON impulse FIELDS expires_at;
DEFINE INDEX IF NOT EXISTS idx_impulse_activity ON impulse FIELDS created_by_activity_id;The expires_at index supports the cleanup query (DELETE FROM impulse WHERE expires_at < time::now()).
Audit lives in the impulse table as a conceptUpkeepAuditLog-shaped impulse, emitted by every mutating concept-db write resolver through a local emitWriteAudit(...) helper. The row it inserts:
{
shape: "conceptUpkeepAuditLog",
pointer: {
operation: "create",
target_table: "concept" | "concept_edge" | "concept_usage" | "concept_sequence",
target_ids: ["<result id, or empty>"],
request_body: { /* the pointer payload as submitted */ },
result_id: "<result id or null>",
performed_by: "<instance id, org id, or 'anonymous'>",
org_id: "<org>",
performed_at: "<ISO timestamp>"
},
summary: "<resolver id> → <target table> (<result id>)",
created_by_resolver_id: "<resolver id>"
}Returned in the write's response metadata under auditImpulseId (matching the activity-api precedent).
Non-blocking: a failed audit insert is logged and returns null for the id, but does not roll back the operation.
Every write resolver invocation produces three things:
- The actual data write — the row(s) in
concept,concept_edge,concept_usage, etc. This is the operation's primary effect. - An audit impulse in the
impulsetable, carrying the request body and the resulting id. - A return value — the write resolver's
contentfield, JSON-stringified. Typically the created row's id and full record so callers can chain follow-up operations.
concept-db emits the audit for every mutating shape, not only destructive ones. Emitting on creates too is what makes "what did this activity write" answerable from the impulse table alone, without joining against the source tables.
This question is broader than write resolvers — it applies to read resolvers that compute something expensive or non-trivial. Examples in concept-db's existing read surface: relatedConcepts, impulseCooccurrenceEdges, conceptUsageStats, conceptSequence. When a caller resolves impulseCooccurrenceEdges for a given trace, the result is itself an impulse — a snapshot of co-occurrence statistics with shape, summary, and content.
Tradeoff:
- Persist (with TTL): future activities can reference past query results by impulse id; the ribosome can extract patterns from query-result history; replay/audit possible. Cost: storage for results that may never be referenced.
- Ephemeral: every consumer re-runs the query. Simpler, cheaper, but loses the "impulse as universal data" benefit — the result exists only in the request/response cycle.
Recommended default policy:
- Always persist results from write resolvers — the audit impulse, which concept-db emits for every mutating shape.
- Persist with short TTL results from expensive read resolvers —
relatedConcepts,impulseCooccurrenceEdges,conceptUsageStats,conceptSequence. Caches subsequent reads and gives the ribosome material to learn from. - Ephemeral for simple lookup reads —
concept(single-row fetch). Cheap to recompute; persisting them is noise.
The read-result caching half is design, not behaviour: nothing sets expires_at on a read result. When it lands, the "expensive vs simple" classification belongs in per-shape resolver metadata rather than hardcoded into the dispatcher, so the policy stays explicit and per-shape.
The impulse table is required regardless, since audit depends on it. The caching policy is described here so the table's design accommodates both audit (persistent, no expiry) and cached results (ephemeral, with expiry).
LifecycleEvent in repos/concept-db/src/lifecycle/dispatcher.ts carries concept:created, concept:resolved, concept:updated, concept:deleted, edge:created, edge:updated, edge:deleted, plus three impulse events so the in-vessel hook system is symmetric across all the vessel's tables:
impulse:created— fired after a successful insert intoimpulse(whether from an audit emission, a cached-result persistence, or a direct write).impulse:resolved— fired when an impulse row'scontentis loaded (for impulses with non-null content this is just a SELECT; for pointer-only impulses, this is the moment a resolver materializes it).impulse:expired— fired by the cleanup path when an impulse is deleted because itsexpires_athas passed.
LifecyclePayload carries an impulse?: unknown slot alongside its concept and edge slots, so handlers subscribe via lifecycleDispatcher.on('impulse:created', ...) exactly as they do for concept events.
This keeps the hook system aligned with the storage layer: a subscriber that learns from concept:created — a ribosome extracting patterns from concept creation, say — extends naturally to learning from impulse:created.
repos/concept-db/sql/core/003-impulse-table.surql follows the existing 001-concept-tables.surql / 002-add-impulse-signature-source-type.surql numbering and is applied by bun run apply-schema, concept-db's standard migration entrypoint. Its contents are the guarded table, field, index and PERMISSIONS statements above.
Purely additive: every statement is IF NOT EXISTS, so applying it against the shared database changes nothing that already exists.
The expiry sweep is available as pruneExpiredImpulses in repos/concept-db/src/resolvers/impulse.ts. Scheduling it belongs in the upkeep scheduler; until then it is invoked explicitly.
Implementation order, not versioning:
Where each piece lives:
-
repos/concept-db/sql/core/003-impulse-table.surql— the guardedimpulsetable, fields, indexes and PERMISSIONS. Applied bybun run apply-schema. -
repos/concept-db/src/routes/impulses.ts— onecaseblock per write shape in the dispatch switch, after the read cases and before thedefault. Each block gates on auth, validates the required pointer field, calls the resolver function, emits the write audit, and returns the envelope:case 'concept_create_write': { if (config.auth.requireAuth && !jwtAuth) { return c.json({ success: false, error: 'Authentication required' }, 401); } const writePointer = pointer as { conceptData?: unknown }; if (!writePointer.conceptData) { return c.json({ success: false, error: 'conceptData required for concept_create_write' }, 400); } try { const request = CreateConceptRequestSchema.parse(writePointer.conceptData); const concept = await createConcept(request, orgId, jwtToken); const auditImpulseId = await emitWriteAudit({ /* … */ }); return c.json({ success: true, content: JSON.stringify(concept), metadata: { shape: 'concept_create_write_result', summary: `Concept ${concept.id} created`, auditImpulseId, }, }); } catch (err) { return c.json({ success: false, error: (err as Error).message }, 400); } }
The same file's
SUPPORTED_SHAPESconst is the advisory list echoed back in the 400 response for an unrecognized shape — it is not read by the dispatcher and does not necessarily enumerate it. TheemitWriteAudithelper at the top of the file is what writes the audit row. -
repos/concept-db/src/config.ts—discovery.shapeslists the write shapes so the vessel advertises them on registration. The intended invariant is that this list and the dispatch switch agree — never advertise a shape with no case — andconceptUpkeepAuditLogis deliberately absent because it is emitted as a side effect and not resolvable by pointer. A shape-dispatch agreement check enforces that invariant bidirectionally —packages/shape-dispatch-checkcompares the advertised list against the dispatch switch and is run per-vessel from each vessel's lint step and fleet-wide byscripts/check-shape-dispatch-all.sh; a case may be excluded deliberately with the@shape-dispatch:privatemarker.SUPPORTED_SHAPESis outside that check's scope and has drifted from the switch in practice, so treat the switch as the ground truth for what resolves. -
repos/concept-db/src/lifecycle/dispatcher.ts—LifecycleEventcarriesimpulse:created,impulse:resolvedandimpulse:expired. -
repos/concept-db/src/resolvers/impulse.ts— the table helpers:createImpulse,writeImpulseToTable,getImpulseById,expireImpulse,pruneExpiredImpulses. Each emits the corresponding lifecycle event. -
Scheduling the expiry sweep — outstanding.
pruneExpiredImpulsesexists but nothing inrepos/concept-db/src/upkeep/calls it on a cadence. -
Tests —
repos/concept-db/tests/write-shapes.test.tscovers the write dispatch surface. The cases worth holding it to:- Each write shape: missing payload → 400.
- Each write shape: well-formed payload → 200 with
metadata.shapeending in_result. org_idfrom JWT propagates to the resolver call.requireAuth = true+ no JWT → 401 for each shape.concept_create_writefollowed by aconceptread returns the created concept.conceptLink_writeagainst nonexistent concepts → 404.conceptSignatureUpsert_write: idempotent — second call returnscreated: false, same id.impulsetable: insert, org-filtered select, TTL cleanup, lifecycle event emission.
No changes required. The generic vessel resolver already speaks the impulse-resolve contract, so _write shapes flow through unchanged.
An optional ergonomics improvement, not required: a helper that constructs a _write pointer given a shape name and payload, with type narrowing per shape.
No contract change. VesselCapability.shapes: string[] is shape-name-agnostic — concept-db just adds more strings.
If clients ever need to filter discovery results to "vessels that can write shape X", that is a future query parameter on the discovery endpoint, not a contract change here.
N/A — the precedent already exists. Confirm docs/impulse-types/LEARNING_LOOP_WRITE_RESOLVERS.md is still accurate when the concept-db doc gets written; if any drift is found, fix it then.
End-to-end: drive the learn-impulse-relationships activity through write resolvers instead of MCP tool calls, and assert the same outcome — signatures upserted as concepts, edges created and refined with EMA weight.
Concretely:
-
Unit (concept-db) — see the test cases listed above under "Implementation outline → concept-db → Tests".
-
Integration (concept-db, in-process) — drive
POST /v2/impulses/resolveend-to-end against a real SurrealDB instance, asserting:- A
concept_create_writeresolution actually inserts a row inconceptwith the expectedorg_id. - A subsequent
conceptread resolution returns the row. - A
conceptLink_writebetween two created concepts inserts aconcept_edgerow. conceptSignatureUpsert_writecalled twice with the same(pointer_type, shape)returns the same id withcreated: truethencreated: false.
- A
-
impulsetable integration — insert an impulse withexpires_at = time::now() + 1s, wait, run the cleanup task, assert the row is gone andimpulse:expiredwas dispatched. Insert withexpires_at = NULL, run cleanup, assert the row remains. -
Discovery roundtrip — register a concept-db instance whose
config.discovery.shapescarries the write shapes, query discovery for shapeconcept_create_write, get back concept-db's capability record, call its advertisedresolve_endpointwithresolve_request_format = "pointer"carrying aconcept_create_writepointer. -
Template-driven — fork the
learn-impulse-relationshipsactivity so its upsert-and-link step usesconceptSignatureUpsert_write+conceptLink_writeresolver impulses instead of MCP tool calls. Run both the original and the fork against the same trace input and assert the resultingconcept_edgerows are identical. -
Trace cross-check — run an activity that uses a write resolver and inspect its execution trace's
impulse_resolutions[]. Every write call should appear with the rightresolver_idandvessel_idpopulated.
-
Should
conceptUpkeepTrigger_writeexist at all, or should that logic move into a vessel-internal scheduler? A template that triggers upkeep imperatively from abashresolver gets no trace; wrapping the trigger in a write shape preserves the imperative call and makes it traceable. The alternative is that concept-db's upkeep scheduler runs on its own and templates never trigger it, reading the upkeep state instead. The latter is more aligned with "vessels own their upkeep" but changes the calling template's execution shape. Flagged for the destructive-shapes work. -
MCP tools and write resolvers coexist long-term — for how long? The recommendation is coexistence. Tools serve human/IDE callers (metabob-mcp); write resolvers serve activity templates. Both call the same underlying resolver function, so behavioural drift is bounded. If telemetry eventually shows tools are only ever called by activities (never by humans/IDEs), revisit and deprecate tools at that point. Don't pre-deprecate.
-
concept_writeandconcept_create_writeoverlap — is carrying both right?concept_writeis the from-source path: it takes asource_typestring that auto-derives shape, budget and priority, which is convenient for seeding but bakes a magic dictionary into the activity layer.concept_create_writetakes explicit fields. Both ship; the open question is whether the source-type defaults table should be documented once and callers pushed toward explicit fields. -
Discovery contract: should
VesselCapabilityseparate read and write shapes? No, but if discovery query traffic for write shapes ever becomes high enough to matter, an additivewrite_shapes?: string[]field onVesselCapability(withshapescontinuing to mean "all shapes") is a backward-compatible extension. Document the option; don't ship it. -
Default TTL for cached result-impulses. An hour is the suggested starting point, tunable per shape. Whether it is right needs operational data — revisit when storage growth or cache-hit rate says otherwise. Nothing sets a TTL until read-result caching is built, so this is not yet a live question.
-
Should the
impulsetable on concept-db ever cross-reference rows in activity-api'simpulsetable? Tempting (e.g. linking aconceptUpkeepAuditLogimpulse to the activity execution that triggered it), but cross-vessel record references are brittle. Better: storecreated_by_activity_idas a free-form string and let consumers query activity-api by that id when they need the linked execution. No cross-vessel foreign keys.
{ "success": true, "content": "<JSON.stringify(handler-body)>", "metadata": { "shape": "<pointer.type>_result", "summary": "..." } }