Skip to main content

Feature Truth — the machine-computed feature ledger

Status: Canonical · Introduced: 2026-07-10 · Owner: Factory platform Engine: scripts/feature-truth/feature-truth.mjs · Reusable workflow: .github/workflows/_feature-truth.yml · Schema: features.schema.json

The law

Humans author intent; machines author status. No file in any repo may contain a hand-typed “Shipped”. A feature’s status is computed by joining signals across surfaces (worker routes, web callers, nav, tests, telemetry). The only status-shaped field a human writes is expect: — a claim the machine then verifies. A claim the signals can’t support is a CI failure, not a docs bug. This mechanizes Phase 3 of the Wingspan Method (BUILT / WIRED / MISSING) so the recon is continuous instead of a manual LLM pass.

Registry layering — which file owns which question

FileQuestion it answersAuthored by
docs/service-registry.yml (Factory)Which workers/services exist, where do they live, who consumes them?human
features.yml (each repo root)What product capabilities is this app supposed to have, and what is each expected to reach?human (intent fields only)
docs/_generated/feature-truth.json (each repo)What rung has each feature actually reached?machine only
docs/FEATURES.md (each repo)Human-readable view of the abovemachine only (generated between markers)
feature-registry.yml (each repo root, schema v1)Marketing/dashboard view for latwoodtech.com/platform/human labels; status overridden by feature-truth.json when present
docs/capabilities/*.yml (Factory)Which routes may the supervisor call, with what blast radius?human
Any other feature inventory in a repo (e.g. a hand-maintained FEATURE_REGISTRY.md status column) is subordinate to feature-truth.json and should be converted or retired. One intent source per repo: features.yml.

The status ladder

Rungs are ordinal; a feature holds the highest rung whose signals (and all below) hold.
RungMeaningSignal
plannedDeclared, no code— (default)
builtBackend/module existsevery declared route resolves in worker source (mount-aware match), or all declared sources files exist
wiredA caller existsroute path found in web source (exact, param-cut, or base-composed prefix — callers often build '/api/x' + '/y'); pages-only features: page implementation found
reachableA user can find ita declared page appears in nav files or has an inbound link from any other page (real apps reach pages through flows, not just global nav); API-only features inherit wired and are flagged api_only
shippedReachable and proven by test@feature F-ID tag in a test/spec or declared tests exist (canonical), or a spec references the feature’s route/page (inferred — bootstraps repos pre-tag-adoption); if per-feature test results are present, 0 failures
aliveShipped and used≥1 of the feature’s analytics_events fired in the last 30 d (feature-usage.json)

The surface field — who calls it

Not every feature has a first-party web caller by design. surface: declares the caller class so the ladder stays honest without lying about wiring:
  • web (default) — first-party web caller expected; full signal chain applies.
  • partner — the caller is external (Stripe webhooks, OAuth IdP consumed by another app, crawler-facing SEO routes). wired/reachable inherit built.
  • internal / ops — no caller expected (crons, queue pipelines, DO internals, admin/ops tooling). wired/reachable inherit built.
Misusing surface to hide a genuinely dark web feature is the one way to cheat this system — reviewers should treat surface: changes on existing features with the same suspicion as a deleted test. Failure states (computed alongside the rung):
  • dark — built but not wired. The classic wingspan delta; latent capability.
  • zombie — shipped, usage data present, zero events in 30 d. Built product meeting no demand.

Hard gates vs advisory (CI semantics)

feature-truth.mjs --check hard-fails on exactly three things:
  1. Overclaimexpect is higher than the computed rung. The claim is a lie; fix the wiring or lower the claim.
  2. Orphan route file — a file under route_files dirs that no feature claims in sources and no exemption covers. This is what forces registration at PR time and prevents duplicate re-builds of unregistered capability.
  3. Schema violations — unparseable YAML, duplicate IDs, unknown expect values.
Everything else is advisory (warnings, exit 0): underclaim (computed rung above expect — raise your claim), stale FEATURES.md, missing optional signal files. Generated-freshness must never hard-block (standing doctrine — see project_docs_antirot history and DOCS_TRUTH_AND_GUARDRAILS.md).

features.yml (schema v2) — annotated example

version: 2
app: capricast                      # matches service-registry.yml id

scan:                               # where the engine looks for each signal
  worker_src: [apps/worker/src]     # route mounts
  web_src: [apps/web/src]           # callers + page implementations
  nav_files:                        # what counts as "findable"
    - apps/web/src/components/Navbar.tsx
    - apps/web/src/components/Footer.tsx
  test_dirs: [e2e, apps/worker/src, apps/web/src]

route_files: [apps/worker/src/routes]   # every file here must be claimed (orphan gate)
exempt_route_files:                      # infra/telemetry routes exempt from ownership
  - apps/worker/src/routes/health.ts

features:
  - id: F-CONF-01                   # stable, F-<DOMAIN>-NN
    title: Conference scheduling
    actor: creator
    story: As a creator, I can schedule a conference so fans can book time with me.
    expect: shipped                 # the human claim — the machine verifies it
    routes:
      - POST /api/conference/rooms  # METHOD /path — used for built + wired signals
    pages: [/studio/conference]     # used for wired + reachable signals
    sources:                        # files this feature claims (orphan gate + built fallback)
      - apps/worker/src/routes/conference.ts
    analytics_events: [conference_scheduled]   # Alive rung (PostHog)
    tests: [e2e/07-conference.spec.ts]          # optional explicit binding
    notes: RealtimeKit room component is RealtimeKitRoom, not ConferenceRoomPanel.
Intent fields (title, actor, story, expect, notes) are yours. Everything in docs/_generated/ and docs/FEATURES.md belongs to the engine.

Test binding (battery integration)

Tag any test that proves a feature with @feature F-ID in its title or a comment:
test('creator schedules a conference @feature F-CONF-01', async () => { … });
  • The tag alone satisfies the shipped rung’s test signal.
  • For per-feature pass/fail (not just existence), add the Vitest reporter scripts/feature-truth/vitest-feature-reporter.mjs — it writes docs/_generated/feature-test-results.json, and the engine then requires the feature’s tagged tests to have 0 failures for shipped.
  • One test may prove several features; tag it with each.

Alive rung (telemetry integration)

scripts/feature-truth/fetch-feature-usage.mjs queries PostHog for every analytics_events name in features.yml (30-day window) and writes docs/_generated/feature-usage.json. Run it on a schedule (not per-PR); the engine merges it when present. Without it, features stop at shipped — absence of telemetry data never fails a build.

Consuming from CI (per app repo)

jobs:
  feature-truth:
    uses: Latimer-Woods-Tech/factory/.github/workflows/_feature-truth.yml@main
    with:
      app_id: capricast
The reusable workflow checks out the calling repo + Factory, runs the engine in --check mode, and uploads feature-truth.json as an artifact. Regeneration of FEATURES.md/feature-truth.json happens locally or on a scheduled job — CI only verifies, so generated-file staleness can warn without blocking.

Who registers a feature

Adding capability = adding a features.yml entry in the same PR. The orphan gate enforces this mechanically: a new route file that no feature claims fails CI. The PR template’s checklist item (“feature registered or exemption added”) is the human-facing mirror of the same rule.

Portfolio rollup

  • scripts/platform_conformance.py scores a Feature truth shadow dimension (presence of features.yml, engine output freshness, zero overclaims/orphans). Shadow = advisory until Stage 4, like every other dimension.
  • .github/scripts/generate-platform-data.mjs (trophy case feed for latwoodtech.com/platform/) prefers computed truth: when a repo publishes docs/_generated/feature-truth.json, dashboard feature status comes from computed rungs, never from hand-typed status: live.

Roadmap (adopted 2026-07-10)

  1. v1 (this kit) — grep-join signals, ladder, gates, Capricast pilot.
  2. Battery binding — reporter adoption in each repo’s vitest/playwright config.
  3. Alive rung — PostHog fetch scheduled per repo.
  4. OpenAPI emission — as repos adopt chanfana/@hono/zod-openapi, the engine reads the emitted spec instead of grepping mounts (per-repo conformance sub-check).
  5. Intent-conformance judge — Quincy-style rubric judge comparing each shipped feature’s story against live behavior (curl + screenshot), weekly, advisory.