Contracts
activation-gate-contract.md
Contract: Activation gate (canonical-URN matching)
Owner: charter/drg.py — _node_is_activated, filter_graph_by_activation Requirements: FR-001, FR-002, C-002, C-004, NFR-001, NFR-002
Obligation
For a per-kind activation set S on PackContext (holding config stems), a graph node of that kind with canonical id A survives the per-ID gate iff A ∈ resolve_stems_to_canonical(S), where resolution reuses charter.kind_vocabulary.resolve_artifact_urn with roots sourced from the PackContext. The gate signature is unchanged.
Behavioral cases
activated_<kind> | Node canonical id | Expected |
|---|---|---|
None (default-allow) | any | kept (byte-identical to merge-base) |
| populated, stem resolves to this node's canonical id | matching | kept (RED on merge-base — the bug — GREEN after fix) |
| populated, does not include this node's canonical id | non-matching | excluded |
| populated with a raw canonical-id (not a stem) | — | not a supported input (require-canonical, C-002); do not add a tolerate-both branch |
| populated with an unresolvable stem | — | skip that stem (catch UnknownArtifactIdError, continue) and let _check_unknown_references report it — matching the two sibling resolution sites consistency_check.py:734-735 and :902-903. The gate MUST NOT raise: filter_graph_by_activation is consumed by five callers including _check_graph_kind_parity, whose contract is fail-closed-report (consistency_check.py:811-812), never crash. |
Implementation notes (binding on the WP)
- Batch resolution once per filter call.
_node_is_activatedis invoked once per node (drg.py:351) andresolve_artifact_urndoes filesystem I/O (rglob). Resolving per-node is O(nodes×stems×fs-walk) and pushes_node_is_activatedpast the ≤15 complexity ceiling. Instead,filter_graph_by_activationbuilds adict[kind, frozenset[canonical_urn] | None]once (lift_build_tension_active_urnsfrom the deleted tension-scan almost verbatim —consistency_check.py:932-956), and_node_is_activatedtakes that pre-resolved map and stays a pure membership check. The public gate signature is unchanged (only the internal helper's). - Compare on full URN.
resolve_artifact_urnreturns a full URN ("directive:DIRECTIVE_001"), while Step 3 today compares the bareartifact_id. Resolve activated stems to full URNs and membership-test againstnode.urn(as the tension-scan does atconsistency_check.py:929). - One doctrine-root source. Source
doctrine_rootfromresolve_doctrine_root()(charter.catalog) — the SAME source the surviving compilerreferences.yamlprojection uses — NOTpack_context.pack_roots[0](a naive__file__join that can disagree in installed/wheel layouts and reintroduce the silent-drop class).org_roots=pack_context.pack_roots[1:]. Add a test pinning gate-doctrine-root == projection-doctrine-root. Prefer liftingorg_roots/doctrine_rootto a namedPackContextaccessor so the gate does not become a third open-coded copy of thepack_roots[1:]slice (compiler:144, tension-scan:940are the existing two).
Attribution proof (NFR-001)
The characterization test must pair: (a) a stem-form populated list → RED on merge-base; (b) a control whose entry already equals the canonical id → GREEN on merge-base. The GREEN control isolates the cause as stem≠canonical, not an incidental populated-list error. Run against the real built-in corpus (and this repo's populated .kittify/config.yaml), never a hermetic id==stem fixture.
Consumer observables (NFR-002)
Each of the five callers gets a before/after test asserting a named observable, not a smoke test: executor.py:182, reference_resolver.py:67, compiler.py:1037 closure, consistency_check.py::_check_drg_cross_kind_refs (:424), context.py:928. None-path byte-identical to merge-base; populated-path emits the corrected per-ID result.
What this contract does NOT change
The compiler references.yaml projection (_resolve_config_activated_ids, compiler.py:88) — it already resolves stems correctly and is a catalog projection, not a graph filter (C-001). It stays.
relation-registry-contract.md
Contract: Relation registry + doc parity
Owner: src/doctrine/drg/models.py (Relation, RELATION_DESCRIPTIONS); tests/doctrine/drg/test_models.py; tests/doctrine/test_relation_doc_parity.py; docs/architecture/doctrine-relationships.md (the single parity surface) Requirements: FR-005, FR-006, FR-007, FR-008, NFR-003, C-006
Obligation
1. Completeness: set(RELATION_DESCRIPTIONS) == set(Relation); all values non-empty. Enforced by test_models.py — convert its == {the 3} pin to == set(Relation) and re-parametrize the non-empty sibling over all members. 2. Distinctness: RELATION_DESCRIPTIONS[APPLIES] != RELATION_DESCRIPTIONS[SCOPE]; each names its distinct edge-role (adjudication, not transcription). 3. Content parity (single surface): for every relation, doctrine-relationships.md has a dedicated ### … section whose body equals RELATION_DESCRIPTIONS[relation] (whitespace-normalized); _SCOPED_RELATIONS widened 3→15. Enforced by test_relation_doc_parity.py. 4. Prose-only surface: docs/context/doctrine.md extended for reader completeness, explicitly NOT under the parity test (it deliberately paraphrases).
Emission-status wording (do not misrepresent)
by actual emission status, not as actively-exercised built-in relations.
vocabulary/refines/delegates_to: 0 edges everywhere → intended-but-dormant.enhances/overrides/replaces: 0 in built-in by design → org-pack overlay relations; describeapplies(1) vsscope(157): distinct edge-roles; describe the contrast.
Constraint
The adjudication describes intent only — no existing graph edges are rewired (157 live scope edges make re-classification out of scope, C-006). Update the now-stale "the other twelve are out of scope / a follow-up" docstrings in test_relation_doc_parity.py and the doc's "Tension vocabulary" prose.