System DesignSLW fleet rivent.dev →

Spine — schema design

SLW Data Engine, Phase 1. Written 2026-08-07 against BRIEF.md, the Future AI plan, the 8/4 code audit (/root/FUTURE-AI-AUDIT-2026-08-04.md on slwdev), slw-webster/migrations/0001–0005, slw-ford's intake_links, and the three live app matchers.

Stop point 1. Nothing is built. No migration has been applied.


One sentence

The spine is the layer that says what a document is about (mentions), what vocabulary the fleet shares (typed tags), how entities relate to each other (edges), and how a new entity gets born (propose, then a human approves) — all of it additive to the registry that already runs, and none of it able to write into any app.


0 · What does not change

Stated first, because every rule below inherits from it.


1 · Where the tables live, and why

Decision: the new tables go in the existing webster schema, in the existing slw-experiments project (akpdtnhnacxkeehheoqz) — not a new spine schema and not a new database.

Three reasons, in order of weight:

  1. The normalization functions are already there and are load-bearing. webster.norm_name, norm_org_name, norm_domain, norm_linkedin, email_domain, is_free_email_domain back generated columns on entities, src_person and src_org. Mentions and tags need exactly the same normalization to join against them. A second schema either duplicates the functions (two definitions that drift) or reaches across (a cross-schema dependency with none of the isolation a separate schema was supposed to buy).
  2. RLS and grants are a per-object surface. 0001 and 0002 end with revoke all ... from anon, authenticated and enable row level security on every table. A second schema doubles the number of places that lockdown has to be re-stated correctly, and the failure mode of getting it wrong is a public read on relationship data.
  3. "Data Engine" is a product concept, not a namespace. The brief is explicit that the three organs stay separate services. Renaming a Postgres schema communicates nothing to any consumer — apps talk to webster.rivent.dev over HTTP and never see a schema name.

Object naming carries the concept instead: everything new is prefixed by what it is (mentions, tags, tag_assignments, entity_edges, entity_proposals, merge_proposals, link_assessments, matching_policy).


2 · The mention store

The problem Ford already solved, and the part it left open

ford.intake_links records (message_id, entity_kind, name) — a pointer, by name, because "the entities live in OTHER systems' registries … Ford linking by name is honest about being a pointer rather than pretending to own the entity."

That honesty is the right instinct and it is also the ceiling. Once the registry exists, "S3" as a string cannot answer which S3, cannot roll up with the LP Flow record for the same firm, and cannot be corrected once. But the fix is not "require an entity id" — a mention is often the first time the fleet has ever seen a name, and refusing to store it until someone resolves it throws away the only evidence that would let anyone resolve it.

The shape

A mention carries a name always, and an entity id when one is known. The name is the durable fact; the entity id is the resolution, added later and reversible.

create table webster.mentions (
  id            uuid primary key default gen_random_uuid(),

  -- WHAT MENTIONS -----------------------------------------------------------
  -- The document, addressed the way its own system addresses it. No FK: the
  -- documents live in Dewey, Ford, Terminal, Podcast and Magellan, and the
  -- registry owns none of them.
  source_app    text not null,          -- ford | terminal | podcast | dewey | magellan | lp_flow | dealflow
  doc_kind      text not null,          -- intake_message | page | literature_post | episode | artifact | note
  doc_id        text not null,
  doc_url       text,                   -- where a human can read it, when derivable
  doc_title     text,                   -- so the queue reads without a cross-system fetch

  -- WHAT IS MENTIONED -------------------------------------------------------
  subject_kind  text not null check (subject_kind in ('person','org')),
  subject_name  text not null,
  subject_norm  text generated always as (webster.norm_name(subject_name)) stored,
  subject_org_norm text generated always as (webster.norm_org_name(subject_name)) stored,
  -- Resolution. NULL means "we have not resolved this yet", which is a normal,
  -- useful state, not a defect.
  entity_id     uuid references webster.entities(id) on delete set null,
  resolved_by   text check (resolved_by in ('exact_key','human','backfill','policy')),
  resolved_at   timestamptz,

  -- HOW IT WAS MENTIONED ----------------------------------------------------
  role          text,                   -- publisher | subject | investor | author | speaker | mentioned
  passage       text,                   -- verbatim quote, Terminal's source_passage discipline
  locator       jsonb not null default '{}'::jsonb,  -- {page: 4} | {char: [120,340]} | {t: "00:14:22"}
  extra         jsonb not null default '{}'::jsonb,  -- sentiment, stage, fit — app-specific, not promoted

  -- PROVENANCE (enforced, not conventional) ---------------------------------
  as_of         timestamptz not null,
  extractor     text not null,          -- 'ford/classifier@2', 'terminal/literature@7', 'manual'
  confidence    numeric(4,3) not null default 1.000
                  check (confidence >= 0 and confidence <= 1),

  created_at    timestamptz not null default now(),
  updated_at    timestamptz not null default now(),

  -- Re-processing a document must never double its mentions.
  constraint mentions_identity unique
    (source_app, doc_kind, doc_id, subject_kind, subject_name, role)
);

create index mentions_doc_idx      on webster.mentions (source_app, doc_kind, doc_id);
create index mentions_entity_idx   on webster.mentions (entity_id, as_of desc) where entity_id is not null;
create index mentions_subject_idx  on webster.mentions (subject_kind, subject_norm, as_of desc);
create index mentions_org_idx      on webster.mentions (subject_org_norm, as_of desc)
                                      where subject_kind = 'org';
create index mentions_unresolved_idx on webster.mentions (subject_kind, subject_norm)
                                      where entity_id is null;

Why each of the load-bearing choices:

The read model

-- "Everything we've ingested about X", newest first, resolved or not.
create view webster.v_entity_mentions as
select m.*, e.canonical_name, e.kind
  from webster.mentions m
  left join webster.entities e on e.id = m.entity_id;

The resolve service answers everything about entity E as entity_id = E OR (entity_id is null AND subject_norm = <E's normalized names>) — so an unresolved mention still surfaces on the entity page, marked as unconfirmed. That is the entire point of storing the name.

Visibility

Mentions inherit the per-link visibility rule from 0005 through their source app: a caller may see a mention iff it may see the source app. The resolve service applies this in the same one-place predicate as visibleLinks(). A mention sourced from an app the caller has no relationship with is invisible and uncountable, exactly as a private link is today.


3 · The typed tag vocabulary

Why the fleet has four vocabularies and needs a fifth

System Today Count
Terminal RESEARCH_SECTORS — a frozen 14-value string union in TypeScript 14
Podcast INTELLIGENCE_TAXONOMY — slug + label, seeded, clamped to other 18
Ford free-text scope.sectors from a model pass, unbounded
LP Flow targeting_tags text[] on deals, orgs and people free
DealFlow organizations.tag, delimited string; no tags on people free

The engine does not get to replace any of them. Terminal's slugs are baked into a prompt and into historical rows; Podcast's own file says renaming a slug orphans history. So:

Decision: the spine holds a canonical vocabulary plus a crosswalk. Apps keep their own strings, unchanged, forever.

create table webster.tags (
  id          uuid primary key default gen_random_uuid(),
  kind        text not null check (kind in ('sector','topic','theme','stage','region')),
  slug        text not null,
  label       text not null,
  parent_id   uuid references webster.tags(id),   -- shallow hierarchy: ai > ai-infra
  description text,
  status      text not null default 'active' check (status in ('active','deprecated')),
  merged_into uuid references webster.tags(id),
  created_at  timestamptz not null default now(),
  constraint tags_slug_unique unique (kind, slug),
  constraint tags_deprecated_requires_target
    check ((status = 'deprecated') = (merged_into is not null))
);

-- Every string any app has ever used, pointing at the canonical tag.
create table webster.tag_aliases (
  id          uuid primary key default gen_random_uuid(),
  tag_id      uuid not null references webster.tags(id) on delete cascade,
  alias       text not null,
  alias_norm  text generated always as (webster.norm_name(alias)) stored,
  source_app  text not null,            -- which book spells it this way
  as_of       timestamptz not null default now(),
  constraint tag_aliases_unique unique (source_app, alias_norm)
);
create index tag_aliases_norm_idx on webster.tag_aliases (alias_norm);

tag_aliases_unique on (source_app, alias_norm) is the important one: within one app a string means exactly one thing, but "Infrastructure" may legitimately mean different tags in Terminal and in LP Flow. The crosswalk is per-app by construction.

Applying a tag

A tag attaches to a document or to an entity. One table, discriminated:

create table webster.tag_assignments (
  id           uuid primary key default gen_random_uuid(),
  tag_id       uuid not null references webster.tags(id) on delete cascade,

  subject_type text not null check (subject_type in ('doc','entity')),
  entity_id    uuid references webster.entities(id) on delete cascade,
  source_app   text not null,
  doc_kind     text,
  doc_id       text,

  salience     int check (salience between 1 and 5),   -- Podcast already models this
  as_of        timestamptz not null,
  extractor    text not null,
  confidence   numeric(4,3) not null default 1.000
                 check (confidence >= 0 and confidence <= 1),
  created_at   timestamptz not null default now(),

  constraint tag_assignments_subject_shape check (
    (subject_type = 'entity' and entity_id is not null and doc_id is null) or
    (subject_type = 'doc'    and doc_id   is not null and entity_id is null)
  )
);

create unique index tag_assignments_doc_unique
  on webster.tag_assignments (source_app, doc_kind, doc_id, tag_id)
  where subject_type = 'doc';
create unique index tag_assignments_entity_unique
  on webster.tag_assignments (entity_id, tag_id, source_app)
  where subject_type = 'entity';
create index tag_assignments_tag_idx on webster.tag_assignments (tag_id, as_of desc);

Seeding

Migration 0007 seeds the vocabulary from what already exists, in this order:

  1. Podcast's 18 slugs become the topic spine — it is the only vocabulary in the fleet that was deliberately designed with slugs, labels and a clamp-to-other rule, and it is already normalized.
  2. Terminal's 14 RESEARCH_SECTORS become sector tags, each with a tag_alias row for its exact display string ("AI/ML", "Deep Tech", …) under source_app = 'terminal'. Terminal's prompt and its historical rows are untouched.
  3. Ford's observed scope.sectors strings are aliased where they match and queued as vocabulary proposals where they do not — a free-text extractor pointed at a controlled vocabulary is exactly the case that produces a long tail, and inventing 200 tags from one model's phrasing would poison the vocabulary on day one.
  4. LP Flow targeting_tags and DealFlow organizations.tag are read from the existing snapshot tables, counted, and the top values aliased. The tail stays un-aliased and visible, not silently dropped.

Nothing in this step writes to Terminal, Podcast, Ford, LP Flow or DealFlow. The crosswalk lives on the spine side only.


4 · Relationship edges

Typed, dated, directional, and never asserted by a model without a human on the other end of the tier bar.

-- The controlled predicate list. Domain/range are enforced, so "a person
-- invested in a person" is impossible rather than merely wrong.
create table webster.edge_predicates (
  slug        text primary key,      -- investor_in | partner_at | founder_of | board_member_of |
                                     -- led_round | advisor_to | lp_in | acquired | spun_out_of
  label       text not null,
  from_kind   text not null check (from_kind in ('person','org')),
  to_kind     text not null check (to_kind   in ('person','org')),
  inverse_of  text references webster.edge_predicates(slug),
  is_dated    boolean not null default true,
  description text
);

create table webster.entity_edges (
  id            uuid primary key default gen_random_uuid(),
  from_entity   uuid not null references webster.entities(id) on delete cascade,
  to_entity     uuid not null references webster.entities(id) on delete cascade,
  predicate     text not null references webster.edge_predicates(slug),

  -- dated, because "was a partner at" and "is a partner at" are different facts
  valid_from    date,
  valid_to      date,
  detail        jsonb not null default '{}'::jsonb,  -- {title:"Partner"}, {round:"Series B", amount_usd:…}

  status        text not null default 'suggested'
                  check (status in ('confirmed','suggested','rejected')),
  confidence    numeric(4,3) not null default 0
                  check (confidence >= 0 and confidence <= 1),
  derived_by    text not null,        -- 'crm_snapshot' | 'terminal/deal@3' | 'manual'
  evidence      jsonb not null default '{}'::jsonb,
  mention_id    uuid references webster.mentions(id) on delete set null,

  source_app    text not null,
  as_of         timestamptz not null,
  decided_by    text,
  decided_at    timestamptz,
  created_at    timestamptz not null default now(),
  updated_at    timestamptz not null default now(),

  constraint entity_edges_identity unique
    (from_entity, to_entity, predicate, valid_from, source_app),
  constraint entity_edges_no_self check (from_entity <> to_entity),
  constraint entity_edges_dates check (valid_to is null or valid_from is null or valid_to >= valid_from),
  constraint entity_edges_decided check ((status = 'suggested') or (decided_at is not null))
);

create index entity_edges_from_idx on webster.entity_edges (from_entity, predicate, status);
create index entity_edges_to_idx   on webster.entity_edges (to_entity, predicate, status);

A trigger mirrors entity_links_kind_guard from 0001: the two endpoints' entities.kind must satisfy the predicate's from_kind/to_kind, checked in the database and not in the service.

Edge status follows the same bar as links. An edge derived from a CRM field the books already agree on (LP Flow's local_persons.local_org_id, DealFlow's investor lists) lands confirmed — it is a copy of a fact a human already entered. An edge extracted from prose lands suggested and joins the review queue. There is no third tier and no model-only confirm.

Audit: link_events is extended by an additive migration to accept an edge_id column (nullable, no FK, same as link_id), so one append-only table remains the audit for every decision the engine records. This is the one place an existing table is touched, and it is an ADD COLUMN ... NULL — no rewrite, no default, no lock beyond the catalog update.


5 · Entity creation — propose, then approve

Webster has never created an entity. It must now, because a mention of a firm nobody has ever filed is the common case, not the edge case. The rule that protects the registry is that creation is a proposal until a human accepts it.

create table webster.entity_proposals (
  id            uuid primary key default gen_random_uuid(),
  kind          text not null check (kind in ('person','org')),
  proposed_name text not null,
  name_norm     text generated always as (webster.norm_name(proposed_name)) stored,
  org_name_norm text generated always as (webster.norm_org_name(proposed_name)) stored,

  emails        text[] not null default '{}',
  domains       text[] not null default '{}',
  linkedin      text,
  title         text,
  firm          text,
  fields        jsonb not null default '{}'::jsonb,   -- everything else the proposer knows

  -- why we think this is new
  reason        text not null,            -- 'no_candidate' | 'all_candidates_rejected' | 'requested'
  near_misses   jsonb not null default '[]'::jsonb,   -- [{entity_id, score, matched_by}] — shown in the queue
  mention_id    uuid references webster.mentions(id) on delete set null,

  status        text not null default 'pending'
                  check (status in ('pending','approved','rejected','superseded')),
  entity_id     uuid references webster.entities(id),  -- set on approval
  decided_by    text,
  decided_at    timestamptz,
  decision_note text,

  source_app    text not null,
  as_of         timestamptz not null,
  proposed_by   text not null,            -- extractor or actor
  policy_version int,                     -- §7
  created_at    timestamptz not null default now(),

  constraint entity_proposals_decided check ((status = 'pending') or (decided_at is not null)),
  constraint entity_proposals_approved_has_entity
    check ((status = 'approved') = (entity_id is not null))
);

create unique index entity_proposals_open_unique
  on webster.entity_proposals (kind, coalesce(org_name_norm, name_norm))
  where status = 'pending';

entity_proposals_open_unique is what stops the queue from filling with 40 copies of the same unfiled firm from 40 documents: the second proposal for the same normalized name attaches its evidence to the open one instead of creating a row. That is a service-side upsert, and the index is the guarantee it holds under concurrency.

Approval is a service transaction, never a trigger: insert the entity, insert the field values with their provenance, resolve every mention whose subject_norm matches and whose entity_id is null, write the link_events row with the actor. Reversible: rejecting later archives the entity rather than deleting it, so the audit survives.

Duplicates get proposed too, never merged. Entity creation makes in-registry duplicates possible for the first time, so the same propose-then-approve rail covers merges:

create table webster.merge_proposals (
  id            uuid primary key default gen_random_uuid(),
  keep_entity   uuid not null references webster.entities(id) on delete cascade,
  merge_entity  uuid not null references webster.entities(id) on delete cascade,
  reason        text not null,          -- 'shared_key' | 'near_name_same_kind' | 'requested'
  evidence      jsonb not null default '{}'::jsonb,
  score         numeric(4,3) not null default 0,
  status        text not null default 'pending'
                  check (status in ('pending','approved','rejected')),
  decided_by    text, decided_at timestamptz, decision_note text,
  as_of         timestamptz not null,
  created_at    timestamptz not null default now(),
  constraint merge_proposals_distinct check (keep_entity <> merge_entity),
  constraint merge_proposals_pair unique (keep_entity, merge_entity)
);

Approving a merge uses the lifecycle 0001 already built for it — entities.status = 'merged' + merged_into — and never deletes a row.


6 · Match assessment (AI on the queue, never past it)

The 1,413 open suggestions break down as: 736 org name matches at 0.65, 503 person name matches at 0.55, 149 single-token org matches at 0.45, 25 name-and-firm at 0.75. The 0.55–0.75 band is 1,264 of them — the band the brief targets.

create table webster.link_assessments (
  id             bigserial primary key,
  link_id        uuid not null,          -- entity_links.id; no FK, assessments outlive links
  edge_id        uuid,                   -- same layer, reused for edge suggestions

  verdict        text not null check (verdict in ('same','different','unsure')),
  rationale      text not null,          -- one line, shown in the queue
  model_confidence numeric(4,3) not null
                   check (model_confidence >= 0 and model_confidence <= 1),

  -- Reproducibility. An assessment that cannot say what produced it
  -- does not get stored — hence NOT NULL on all four.
  model          text not null,          -- 'claude-haiku-4-5'
  prompt_version int  not null,
  policy_version int  not null references webster.matching_policy(version),
  input_digest   text not null,          -- sha256 of the exact rendered comparison

  cost_usd       numeric(10,6),
  latency_ms     int,
  created_at     timestamptz not null default now(),

  constraint link_assessments_rerun unique (link_id, model, prompt_version, policy_version)
);

create index link_assessments_link_idx on webster.link_assessments (link_id, created_at desc);
create index link_assessments_verdict_idx on webster.link_assessments (verdict, model_confidence desc);

The queue sorts and groups by (verdict, model_confidence desc) so a reviewer can bulk-accept a block of same at high confidence, and unsure sinks to where it belongs. decide() is unchanged — same endpoint, same actor, same link_events audit. The model's only power is ordering.

The review-queue UI change (verdict grouping, bulk-accept-by-verdict) is a real visual change and gets a published HTML mockup before it is built. Not part of this stop point.


7 · Matching policy as configuration

Today the tier weights live as literals in service/lib/registry.ts (1.000 email, 0.980 linkedin, 0.950 domain, 0.750 name_and_firm, 0.650/0.550 name) and in scripts/seed.sql (0.45 single-token org). Terminal has its own copy in matching.ts CONFIG (acceptThreshold: 3, highConfidence: 6, reviewFloor: 1). LP Flow has a third in intel-matching.ts (0.99 / 0.95 / 0.55 / 0.7). Three matchers, three constants files, three deploys to change a threshold.

create table webster.matching_policy (
  version     int primary key,
  weights     jsonb not null,     -- {"email":1.0,"linkedin":0.98,"domain":0.95,
                                  --  "name_and_firm":0.75,"name_org":0.65,
                                  --  "name_person":0.55,"name_org_single_token":0.45}
  thresholds  jsonb not null,     -- {"auto_link":1.0,"ambiguous":[0.55,0.75],"floor":0.40}
  options     jsonb not null default '{}'::jsonb,  -- free-mail list overrides, token rules
  note        text,
  is_active   boolean not null default false,
  created_by  text not null,
  created_at  timestamptz not null default now()
);

create unique index matching_policy_one_active on webster.matching_policy (is_active)
  where is_active;

Version 1 is seeded with exactly today's literals, so activating it is a no-op and the first migration proves the mechanism without moving a single score. Every link_assessments row and every auto-link records the policy_version that produced it, so changing a threshold is a settings edit plus an optional re-run — not a deploy, and not a silent re-interpretation of history.

The one thing policy may not express is a bar that auto-confirms a name match. auto_link is validated at write time against the exact-key tiers only. That rule is in the schema, not in a code review.


8 · How Ford's intake_links maps on

Ford keeps its table. It is Ford's own ledger of what its intake attached to, it is already live, and the brief's rule is that nothing in a consumer app gets rewritten. The backfill copies forward.

ford.intake_links Spine Notes
message_id mentions.doc_id, with source_app='ford', doc_kind='intake_message' joined to ford.intake_messages for doc_title and the real as_of
entity_kind = 'firm' mentions.subject_kind='org', role='publisher' Ford's firm is always the publisher of a newsletter
entity_kind = 'company' mentions.subject_kind='org', role='subject'
entity_kind = 'sector' tag_assignments, subject_type='doc', kind='sector' not a mention — see §2
name mentions.subject_name entity_id left null; resolution is a separate, reviewable step
created_at mentions.created_at as_of comes from the message, not from this
extractor = 'ford/classifier@1' so a re-extraction is distinguishable

Volume today: 11 links across 3 messages. This backfill is the cheap one and goes first precisely because it is small enough to eyeball end to end.

Ford's forward path (Phase 2, not this session): spineLinks() in packages/intake/src/pipeline.ts gains one additional call that POSTs the same links to the engine. It keeps writing intake_links — dual-write, because a door that depends on a second service being up is a worse door.


9 · How the three matchers call it

The audit's headline risk is matcher fragmentation: "the spine must become the one matcher everyone calls." Concretely, per app:

Terminal — artifacts/api-server/src/lib/{matching,aiMatch,dealflow}.ts

Today: a weighted signal vote (name 3, domain 6, investors ≤8, sector 2, with negative signals) → accept | review | reject; the review band goes to a Haiku tiebreaker that fails closed; results cached in dealflow_links with a manual override.

LP Flow — artifacts/api-server/src/lib/intel-matching.ts

Today: five tiers (exact normalized name 0.99 → proposal alias 0.95 → LP profile alias 0.95 → trigram Jaccard ≥0.55 → contact-email domain 0.70), plus org_match_exclusions (learned negatives) and person_name_variants (learned name variants). Nothing auto-applies.

DealFlow — the thinner Workbench clone

Least code, so it gets the registry tier first and keeps the least local matching. Same shape as LP Flow: registry tier above fuzzy, learned negatives read as priors, nothing auto-applied.

Podcast — the private registry

Podcast1's entities table (kind, normalized_key, alias accumulation, mention_count) is a well-built registry that no other app can see, and entity_mentions is already a mention store in everything but name.


10 · Migrations

Additive only, numbered after 0005, pg_dump immediately before each apply, backup path recorded in STATUS.md. Each ships with a read-back script asserting the new tables exist and that row counts on all five existing tables are unchanged.

# File Contents
0006 spine_mentions.sql mentions, indexes, v_entity_mentions, RLS + revoke
0007 spine_tags.sql tags, tag_aliases, tag_assignments, vocabulary seed
0008 spine_edges.sql edge_predicates + seed, entity_edges, kind-guard trigger, link_events.edge_id
0009 spine_proposals.sql entity_proposals, merge_proposals
0010 matching_policy.sql matching_policy + version 1 seeded to today's literals
0011 link_assessments.sql link_assessments, queue view with verdict grouping

0010 precedes 0011 because link_assessments.policy_version references it.


11 · API surface (Phase 1)

Same service, same two credential tiers, same conventions as resolve/entity/suggestions/decide/peek.

Method Path Scope Notes
POST /api/mentions write batch upsert, idempotent on the identity key; returns resolved/unresolved counts
GET /api/mentions read by entity_id, or by app+doc_id; visibility-filtered
GET /api/tags read vocabulary + aliases; the crosswalk any app can read
POST /api/tags/resolve read free string → canonical tag, or null (never invents one)
POST /api/edges write suggested by default; confirmed only for exact-key derivations
GET /api/edges read by entity, predicate, date range
POST /api/entity-proposals write dedupes onto an open proposal
POST /api/entity-proposals/:id/decide decide approve creates the entity + link + audit
POST /api/merge-proposals/:id/decide decide approve sets merged, never deletes
POST /api/assess decide runs or returns a cached assessment; cannot change a status
GET /api/policy · POST /api/policy read / decide read the active policy; publish a new version

Every write endpoint requires source_app, as_of and extractor, and 400s without them. Provenance is enforced at the schema level and refused at the door, because a nullable column nobody fills is not provenance.


12 · What this design deliberately does not do


13 · Open questions for Shawn

Three, and only the first blocks the migrations.

  1. Ford's unmatched sector strings. The classifier emits free text. My recommendation: alias what matches the seeded vocabulary, and hold the rest as vocabulary proposals in the queue rather than auto-creating tags — a controlled vocabulary that any model can extend is not controlled. That means Ford's tag coverage starts partial and improves as you approve. The alternative is auto-create-and-prune later, which is faster now and messier forever. Blocks 0007.

  2. Podcast entity volume. Podcast's private registry is the largest source of entity_proposals and every one needs a human. If that turns out to be hundreds of rows, do you want them queued in full, or gated to entities with a mention count above some floor (say, mentioned in ≥2 episodes) with the long tail held? Answerable after the 50-item sample at stop point 2 — not blocking now.

  3. Who may approve an entity proposal. decide() today takes an actor string and the review-queue token is single-tier. Entity creation is a heavier action than confirming a link. Same token, or a separate one? My recommendation: same token for now — the audit trail already records the actor, and a second credential tier is real overhead for a one-reviewer registry. Revisit when someone other than you reviews. Not blocking.


14 · Decisions recorded here

Copied into slw-webster/docs/DECISIONS.md when the first migration lands.

Edit this page·History