RFC-0024 — Package-Owned Runtime Invariants

Status: Proposed · Created: 2026-10-15 · Owner: each service, PFactory first · Extends: the PFactory pilot (apps/backend/factory_invariants, scripts/check_invariant_ownership.py, PFactory#622) · See also: RFC-0006 (VAL levels, a different axis; see §5) · Affects: PFactory now; CFactory, TFactory and AIFactory later, each behind the gate in §6.

Tracking: Factory#818. Intent, spec and plan: intent/, spec/ and plan/2026-10-14-818-package-owned-invariants.md.

1. Problem

A check that was never written, never wired up, or quietly stopped running looks exactly like a check that passed. Factory#818 records six cases: Factory #805, #807 and #816, AIFactory #1351, and PFactory #595 and #601. Its comments add five more found after those fixes: Factory #932 and #858, TFactory #1148, and AIFactory #1387 and #1390. Each was fixed on its own, and new ones kept appearing. The #971 audit then found the runtime form of the same class: final statuses that never reach the place they are read.

PFactory has a pilot of the fix. Measured on 2026-10-14, it falls short in three ways:

2. Decision

Adopt package-owned runtime invariants one service at a time, with every package covered within each service it takes on. PFactory goes first. Partial coverage inside a service is not allowed: as #818 says, covering only the riskiest packages gives up the property that makes the pattern work.

3. Coverage

3.1 The unit is the top-level package

A package is a directory with an __init__.py directly under one of the service’s declared source roots. For PFactory those roots are apps/backend and apps/web-server.

The pilot asks for one declaration per module. PFactory’s backend packages hold 560 non-test modules, 155 of them in plan alone. At that size most declarations would be written reasons, which is noise. So the rule counts packages. Each package has one companion, <pkg>/_invariants.py, which registers at least one check or one written reason. A companion may still register per-module checks when that is the natural boundary.

3.2 Discovered, not listed

The checker walks the declared source roots and requires a companion for every package it finds. Three things fail CI:

The only hand-written inputs are the list of source roots and a map of vendored exceptions. factory_common is vendored byte-for-byte from the hub, and its drift gate rejects extra files inside it. So its companion stays a sibling file, factory_common_invariants.py. Every entry in the exception map must exist on disk.

3.3 Guards kept from the pilot

The checker keeps its --self-test, which proves it can fail for each source root.

4. Running in production

4.1 Readiness, not liveness

PFactory’s readiness and liveness probes both call /api/health (factory-gitops apps/pfactory/manifests/manifests.yaml). If an invariant failed /api/health, liveness would fail too and the pod would restart in a loop. So the RFC makes three changes:

A critical check must be cheap and local. It makes no network calls, so a slow dependency can’t take every pod out of service.

4.2 Non-critical checks

Non-critical checks run on a timer inside the app. The service sets the period, and the default is 5 minutes. Each violation does two things:

4.3 Switches

4.4 Promotion

A new check runs non-critical for one release. It becomes critical only if it raised no false positive in that release. This turns the pilot’s “a check earns production” rule into a concrete test.

5. How this relates to existing checks

6. Rollout and cost

  1. PFactory.
    • The discovery checker and companions for the 29 uncovered packages. Most companions are a written reason of a few lines.
    • critical, /api/ready, the timer and the metric. That is roughly 150 lines across the registry, the endpoint and the checker.
    • Move the readiness probe in factory-gitops, after the previous item is deployed.
  2. Gate. The next service starts only after PFactory has two things: full coverage, and one release run with the checks on in production.
  3. Order after PFactory:

    Order Service Packages Why this place
    1 CFactory 3 It proves the pattern ports to another service.
    2 TFactory 31  
    3 AIFactory 30  
  4. No shared library. Each service copies factory_invariants, which is about 180 lines. A copy costs less than a new vendored dependency.

7. Alternatives rejected

Alternative Why rejected
Per-module coverage, as the pilot does it 560 declarations in PFactory alone, mostly written reasons. The cost buries the signal.
The whole fleet at once About 95 companions before the pattern has proved itself in one production service.
Retiring the pilot The failure class keeps recurring (11 cases), and the hub already depends on the pilot’s rules.
Failing /api/health It is also the liveness probe, so the result would be a restart loop.
Stopping startup on any violation One false positive would take a whole service down.
A shared package vendored from the hub A new vendored dependency for about 180 lines.

8. Risks

9. Follow-up issues

Repo Issue What
PFactory PFactory#864 A checker that finds every package on disk, plus companions for all packages (§3)
PFactory PFactory#865 critical, /api/ready, the timer and the metric (§4)
factory-gitops factory-gitops#283 Move PFactory’s readiness probe to /api/ready, after PFactory#865 is deployed
Factory Factory#3591 A rejected/ record of designs, the side idea in #818

CFactory, TFactory and AIFactory issues are filed when the gate in §6 opens.

Appendix: measurements

Taken on 2026-10-15 against each repo’s origin/dev, in a clone, read-only.

# Top-level packages under the service's source roots (§3.1, §6)
git ls-tree -r --name-only origin/dev apps/backend apps/web-server \
  | grep -cE '^apps/(backend|web-server)/[^/]+/__init__\.py$'
# PFactory 32 · TFactory 31 · AIFactory 30 · CFactory 3

# PFactory non-test modules in backend packages (§3.1)
git ls-tree -r --name-only origin/dev apps/backend \
  | grep -E '^apps/backend/[^/]+/.*\.py$' \
  | grep -vcE '/tests?/|/test_|_invariants\.py$'
# 560

# Who calls the registry in PFactory (§1): only the pilot, its tests and the checker
git grep -l -E 'factory_invariants|verify_all' origin/dev -- '*.py'