---
type: Axis
title: searchability (lookup-reach axis)
description: The searchability/lookup-reach architecture axis — how a record can be found (findable-by-primary-key → +secondary-index → +full-text → by-any-unique-id). A per-entity-with-default axis whose commitment surface is the persistent-data schema (indexes) plus the interface/wire contract (the query API). DEEP (RFC #75 5A.5 / P1 #851); worked example is the FTS5 search surface (#354/#701) and the a4 UUID cross-tier lookup.
tags: [axis, architecture, searchability, search, index, fts, lookup, rfc-75, deep]
timestamp: 2026-06-20T00:00:00Z
---

# Searchability (lookup-reach axis)

> **DEEP axis (P1 #851, resolves OQ8 option C).** Full §6 depth with a worked
> example from the skill's own history — the FTS5 full-text search surface
> (#354/#701) and the a4 UUID-based cross-tier lookup. Distinct from
> `schema-flexibility` (how much a row's *shape* can vary) and `persistence`
> (how *durably* it is stored): searchability is the set of *access paths* by
> which a stored record can be retrieved.

## Concept

Searchability is the **lookup-reach** axis (RFC #75 §3): how a persisted record
can be found. The ladder runs findable-by-primary-key (you can fetch it only if
you already hold its PK) → +secondary-index (queryable by indexed attributes:
status, milestone, type) → +full-text (free-text search over body content: an
FTS index) → by-any-unique-id (resolvable from ANY of its stable identifiers —
PK, UUID, slug, external ref — through a unified resolver). Its **commitment
surface is the persistent-data schema** (indexes are schema) **plus the
interface/wire contract** (the query API the index backs) (§4): an index you
never built means rows written at the lower rung are not retrievable that way
without a backfill, and the query contract callers depend on cannot be narrowed.
Its **scope is per-entity-with-default** (§5): a system-wide default
("findable-by-PK + the obvious secondary indexes") with per-entity escalation
to full-text or multi-id resolution.

## Why it is an axis (discriminator test — §2)

1. **Ordered tiers** — each rung strictly widens the set of access paths
   (PK ⊂ +secondary ⊂ +full-text ⊂ by-any-id).
2. **Cross-cutting bundle** — a rung dictates the index set (and its write-time
   maintenance cost), the query API surface, the resolver/dispatch layer, the
   storage for the index, and the search-correctness tests.
3. **Asymmetric migration cost** — adding an index is a backfill over existing
   rows (cheap to add forward; the rows written before it are invisible to that
   path until rebuilt); a full-text or multi-id resolver is a new subsystem.
   Narrowing the query contract breaks callers that used the path.
4. **Requirement-keyed selection** — falsifiable probes (below) pick the rung.

## Tiers (the ladder)

| Tier | Adds | Commitment surface | Forecloses next-cost |
|---|---|---|---|
| `by-primary-key` | PK fetch only | persistent-data-schema | trivial → add a secondary index |
| `secondary-index` | indexed-attribute query | persistent-data-schema | low; exact-match only |
| `full-text` | free-text body search | persistent-data-schema | medium; needs an FTS engine |
| `by-any-unique-id` | unified multi-id resolver | interface-wire-contract | high; a resolver subsystem |

## Schema view (the §6 entry — validated by `axis_catalog`)

```yaml
id: searchability
kind: architecture-axis
scope: per-entity-with-default
default_tier: secondary-index
maintenance_discipline: >-
  records are findable by PK plus the obvious secondary indexes by default; an
  index is a deliberate schema addition whose write-time cost is accepted. A
  per-entity escalation to full-text or multi-id resolution is declared in the
  entity record's axis-materialization facet and carries a BACKFILL obligation —
  the index must be (re)built over existing rows, or rows written before it are
  invisible to that path (the FTS-empty-until-backfilled scar: the canonical DB
  had NO FTS and /search silently returned 0 even after a daemon restart, #745).
  The query contract a tier advertises cannot be narrowed without breaking
  callers. Per-entity deviations live in the entity record.
selection:
  probes:
    - "Will callers always already hold the record's primary key? → by-primary-key."
    - "Must records be queried by attributes (status, milestone, type)? → secondary-index."
    - "Must callers search free text inside record bodies? → full-text."
    - "Must a record be resolvable from ANY of several stable ids (PK, UUID, slug, external ref)? → by-any-unique-id."
    - "Does a search surface exist but return 0 on real data? → the index was never built/backfilled."
composes_with:
  - "searchable-evolving-shape = searchability × schema-flexibility (you cannot index an EAV blob like a column)"
  - "scoped search = searchability × tenancy (a tenant sees only its own results)"
applies_when: []
tiers:
  - tier: by-primary-key
    commitment_delta:
      surface: persistent-data-schema
      delta: "a record is retrievable only by its primary key; there is no other access path. Callers must already hold the id."
    admin_tool: "none beyond a direct get-by-id."
    enforcement:
      - "the only query is get(pk); no attribute or text query exists."
    tests:
      - "get-by-pk returns the row; a query by any other attribute is not supported."
    forecloses: "finding a record you don't already have the key for."
    migration_cost_to_next: "low — add a secondary index on a queried attribute (+ backfill)."
  - tier: secondary-index
    commitment_delta:
      surface: persistent-data-schema
      delta: "indexed attributes (status, milestone, type) are queryable by exact/range match; each index costs write-time maintenance + storage."
    admin_tool: "index registry; an index-usage + bloat report."
    enforcement:
      - "every query attribute is backed by an index or documented as a scan; new indexes are backfilled over existing rows."
    tests:
      - "a query by an indexed attribute returns the right rows including pre-existing ones (backfill held)."
    forecloses: "free-text search; querying un-indexed attributes cheaply."
    migration_cost_to_next: "medium — add a full-text index over body content (an FTS engine)."
  - tier: full-text
    commitment_delta:
      surface: persistent-data-schema
      delta: "free-text search over record body content via an FTS index (FTS5); ranked matches, tokenization. The FTS index must be kept in sync with writes."
    admin_tool: "FTS index inspector; a reindex/rebuild tool."
    enforcement:
      - "every body write updates the FTS index; the index is rebuilt on schema change; /search reads the index, not a LIKE scan."
    tests:
      - "a free-text query returns ranked matches over existing rows; a fresh write is searchable; a missing index FAILS loudly (no silent 0)."
    forecloses: "treating absence-of-results as absence-of-data (the silent-0 trap)."
    migration_cost_to_next: "high — add a unified resolver over all of a record's stable ids."
  - tier: by-any-unique-id
    commitment_delta:
      surface: interface-wire-contract
      delta: "a record resolves from ANY of its stable identifiers — PK, UUID, slug, external ref — through one resolver/dispatch the query contract advertises."
    admin_tool: "id-resolver console; an id-collision/ambiguity audit."
    enforcement:
      - "the resolver maps every kind of id to the canonical record; an ambiguous id is rejected, not silently mis-resolved."
    tests:
      - "the same record resolves identically from its PK, its UUID, and its slug; an unknown id errors cleanly."
    forecloses: "callers being forced to know which id-kind they hold."
    migration_cost_to_next: "n/a — top of the ladder."
examples:
  - "The skill's own climb: the search surface moved from by-PK/secondary-index to FULL-TEXT via an FTS5 index over issue/discussion/turn bodies (#354 alert store, #701 search). The FTS-empty-until-backfilled scar (#745) is exactly this axis's backfill obligation — the canonical DB had no FTS and /search read action-log.db returning 0, a silent missing-index failure, NOT an empty store. The a4 UUID cross-tier lookup is the by-any-unique-id rung: a sidecar/entity/system resolves from its UUID across tiers (RFC #81 §11), not only from its primary key."
```

## Selection procedure (§8)

Bidirectional check per entity under the secondary-index default. The escalation
pass asks "must callers search free text / resolve by a non-PK id?" — a yes forces
full-text or by-any-unique-id, *with a backfill*. The de-escalation pass asks
"is every lookup by PK?" Recorded in the entity record; a `/search`-style surface
that returns 0 on a populated store is a systems-analysis finding (the index was
never built — the silent-0 trap, #745), surfaced by the drift detectors (5A.5).

## Composition (§5)

Searchability is half of two **products** (derived, not authored here):
searchable-evolving-shape = **searchability × schema-flexibility** (you cannot
index a document/EAV blob the way you index a typed column — flexibility caps the
reachable searchability tier); scoped search = **searchability × tenancy** (a
search must filter to the caller's tenant). The interaction table derives these
(5A.3).
