Skip to content

Fact Lowering

lowerShapeModules in packages/shp-checker/src/checker/lowerer.ts turns every parsed ShapeModule into one Model before any rule runs. Lowering resolves names, applies change declarations, and records each claim twice: as typed index entries, which rules read, and as Fact records with provenance, which callers can request. Paths on this page are relative to packages/shp-checker/src/.

Lowering is global. Any change to any document rebuilds the whole Model and fact list, and IncrementalShapeChecker reuses a lowered model only while the documents are unchanged. The reuse rules are in Checker Pipeline.

Model in checker/model.ts holds the typed indexes, the fact list, and the lowering diagnostics:

FieldHoldsRead outside lowering by
moduleseach module’s name, imports, file path, and generated-AST flagnothing (lowering uses it to re-scope change entries)
declarationsdeclaration names per kind and modulenothing (name resolution in checker/symbols.ts uses it during lowering)
resourcesResourceInfo: traits and fingerprintsrules/functions.ts, rules/names.ts, rules/relations.ts, derivations.ts, query.ts, inspection.ts
traitsTraitInfo: type parameters, forbid patterns, context requirements; seeded with the prelude traitsrules/names.ts, rules/guards.ts, derivations.ts
componentsComponentInfo: classifiers, owns, grants, and FunctionInfo per fnrules/functions.ts, rules/names.ts, rules/context.ts, rules/relations.ts, derivations.ts, query.ts, inspection.ts
hypergraph.edgesone HyperedgeInfo per kept relationrules/names.ts, rules/relations.ts, derivations.ts, query.ts, inspection.ts
hypergraph.incidencevertex name to the names of its hyperedgesquery.ts only: graphShapeModules (shp graph show) and the relations section of explainShapeModules (shp explain)
candidateEffectseffect candidate declarationsrules/names.ts, rules/functions.ts
implementationspath globs, conforms_to, and the on_change requirementrules/coverage.ts, rules/names.ts, derivations.ts, inspection.ts
bindingswhen_changed, require_changed, and allow attest entriescheckBindings, inspection.ts
rulesRuleInfo for each rulerules/declarations.ts, rules/names.ts, rules/relations.ts, derivations.ts, inspection.ts
rationales, memoriescontext info with flattened guard blocksrules/context.ts (which also feeds rules/guards.ts), derivations.ts, query.ts; memories also inspection.ts
reevaluationsReevaluationInforules/context.ts, derivations.ts
roles, policiesdeclared review roles and approver policiesderivations.ts (reevaluation validation)
attestationskind, normalized path, reason, provenancerules/coverage.ts
shapeUpdatePathsnormalized source path to the provenance of every ref that names itrules/coverage.ts
changeEventsguard events produced by change declarationsrules/guards.ts
factsFact[]checkLoweredShapeModel, only when includeFacts is set
diagnosticslowering diagnosticscheckLoweredShapeModel, which reports them with the rule output

facts is a list of kind-first records. The Fact union in checker/model.ts is the authoritative list of kinds and fields. Every fact carries provenance: { filePath?, label }, and describeProvenance in checker/provenance.ts renders that provenance for caused by: lines, dropping module qualifiers.

No rule module reads model.facts. Production rules and the explain, graph, memory, obligations, and inspect helpers read only the typed indexes. The fact list leaves the checker only when a caller sets includeFacts; checkLoweredShapeModel then returns it sorted by its JSON text. Its consumers are the checker tests, the Datalog spike, and the semantic-kernel harness. Rule Evaluation lists why the facts are not a complete rule database.

  1. preludeTraitSeed() in checker/prelude-seed.ts fills model.traits with the prelude traits: AppendOnly with its three final forbids, and the context-obligation traits from PRELUDE_CONTEXT_REQUIREMENTS in prelude.ts. Then, for every module, lowerShapeModules builds the module’s lowering context with moduleContext and records it in model.modules, and indexModuleDeclarations records the names of its resources, components, traits, relations, candidate effects, implementations, bindings, rationales, memories, reevaluations, and rules in model.declarations (roles, policies, attestations, and change declarations are not indexed). Every module is indexed before any declaration is lowered. Name resolution (resolveDeclReference in checker/symbols.ts) consults this index, so a reference resolves the same way whichever file declares its target.
  2. For each module in input order, each non-change declaration is lowered in source order by its domain lowerer: lowerResource, lowerTrait, lowerComponent (which calls lowerFunction and emitFunctionFacts for each fn), lowerRelation, lowerCandidateEffect, lowerImplementation, lowerBinding, lowerAttestation, lowerRule, lowerRationale, lowerMemory, lowerReevaluation, lowerRole, and lowerPolicy. For resources, traits, components, relations, candidate effects, bindings, rationales, memories, and reevaluations, the first declaration of a name wins, and a later duplicate is dropped with a duplicate_declaration diagnostic.
  3. Each change declaration, in the same module and source order, is applied by lowerChange. See Changes.
  4. rebuildShapeUpdatePaths (private to lowerer.ts) drops every shape_update_for fact, then rebuilds model.shapeUpdatePaths from the final function registry with collectShapeUpdatePathsFromFunction, re-emitting one shape_update_for fact per ref.
  5. emitDerivedFacts in lowering/facts.ts emits trait_final_forbid and context_required facts from the final traits and trait bearers. These are the only derived facts.

This model passes shp check:

module history
resource Revision : AppendOnly
component RevisionLog {
owns Revision
grants Append<Revision>
fn appendRevision
source ts("src/history/log.ts#appendRevision")
effects complete {
Append<Revision>
evidence ts("src/history/log.ts#appendRevision")
}
}

Checked with includeFacts: true, it returns these 12 facts, in the returned order. Each provenance filePath is history.shape, except the prelude rows, whose filePath is standard prelude:

kindOther fieldsprovenance.label
componentname: history::RevisionLogcomponent history::RevisionLog
effectcomponent: history::RevisionLog, functionName: appendRevision, effect: Append, target: history::Revisioneffect history::RevisionLog.appendRevision emits Append<Revision>
functioncomponent: history::RevisionLog, name: appendRevisionfn history::RevisionLog.appendRevision
grantscomponent: history::RevisionLog, effect: Append, target: history::Revisioncomponent history::RevisionLog grants Append<Revision>
ownscomponent: history::RevisionLog, resource: history::Revisioncomponent history::RevisionLog owns history::Revision
resourcename: history::Revisionresource history::Revision
resource_traitresource: history::Revision, trait: AppendOnlyresource history::Revision : AppendOnly
shape_update_forpath: src/history/log.ts (from the evidence)effect history::RevisionLog.appendRevision emits Append<Revision>
shape_update_forpath: src/history/log.ts (from the source)fn history::RevisionLog.appendRevision
trait_final_forbidtrait: AppendOnly, effect: DropStorage, target: Ttrait AppendOnly forbids final DropStorage<T>
trait_final_forbidtrait: AppendOnly, effect: HardDelete, target: Ttrait AppendOnly forbids final HardDelete<T>
trait_final_forbidtrait: AppendOnly, effect: Truncate, target: Ttrait AppendOnly forbids final Truncate<T>

Declared names are module-qualified (history::RevisionLog); function names inside function and effect facts are local, and the prelude trait keeps its bare name. shape_update_for paths drop the #appendRevision anchor. The effect fact records only the effect and its target: the evidence ref stays on the function’s effect entry (EffectEntryInfo.evidence) in the components index, where checkFunctions reads it for the evidence: line of a forbidden effect diagnostic.

No fact says that Revision forbids HardDelete<history::Revision>. That per-resource forbid is computed on demand from the indexes, as shp explain shows:

$ shp explain Revision history.shape
Revision
kind: resource
traits:
AppendOnly
final forbidden effects:
HardDelete<Revision>
Truncate<Revision>
DropStorage<Revision>

lowerTrait stores a TraitInfo in model.traits under the module-qualified name. It records the type parameters, every forbid member as a forbid pattern with a final flag, and every require_context member as a context requirement. A require_context member whose <target> is not a declared type parameter, or whose bound is not Fn, Component, or Resource, is dropped with an invalid_require_context diagnostic. allow and require members are parsed but not lowered.

lowerResource stores each resource’s resolved trait names in ResourceInfo.traits and emits one resource_trait fact per trait.

The two halves meet at rule time. checkFunctions calls findFinalForbidden, which calls deriveFinalForbidsForResource in checker/derivations.ts. That function instantiates each final pattern of each of the resource’s traits for that resource (a generic or omitted target becomes the resource; a concrete target stays as written), then adds final forbids derived from rule declarations. shp explain calls the same function. Lowering therefore stores no per-resource forbid, and neither per-resource nor rule-derived forbids ever become facts.

emitDerivedFacts emits one trait_final_forbid fact per forbid member of every trait, with the pattern’s generic target (for example T). It emits these facts for plain forbid members too, and the fact has no final field; only its provenance label shows final.

Obligations live on the trait itself: prelude obligations are seeded onto prelude traits, and a user trait’s require_context members are stored on that trait. A bearer’s obligations come from the traits it bears, through requirementsForTarget. A same-module trait with a prelude name shadows the prelude trait during name resolution, so a bearer in that module gets only the local trait’s obligations. Nothing merges.

lowerRelation in lowering/relations.ts first validates the relation’s structure, reporting each problem as an invalid_relation diagnostic. A missing kind or connects, fewer than two endpoints, a duplicate endpoint, or an arity or connection form that a prelude kind does not allow drops the relation. A repeated kind, connects, roles, or summary member, or a roles or expects entry that names no endpoint, is reported without dropping it. A kept relation becomes a HyperedgeInfo in model.hypergraph.edges and emits:

  • one hyperedge fact, with relationKind and ordered (true for A -> B connects, false for { A, B });
  • one hyperedge_member fact per endpoint, with its index and, when roles names it, its role;
  • one hyperedge_fingerprint_expectation fact per expects entry.

A binary dependency is a two-member hyperedge; there is no separate binary-edge layer.

Lowering also builds model.hypergraph.incidence, a vertex-to-hyperedge index keyed by endpoint name, which shp graph show and shp explain use. Rules do not read it. forbid provides T except C scans every provides hyperedge, and forbid path and forbid hypercycle build a directed step graph from the hyperedges, filtered by kind; see Rule Evaluation. Whether each endpoint names a declared component or resource is checked at rule time by checkResolvedNames.

lowerRationale and lowerMemory in lowering/context.ts store a RationaleInfo or MemoryInfo. lowerContextMember flattens the grouped blocks into shared fields:

  • each protects entry becomes a ProtectedProperty; pushProtects resolves a shape value as a trait name, so it matches the shape_trait_removed events a change produces;
  • each guards entry becomes a GuardInfo (on_change require) or a TransformGuardInfo (forbid transform), through pushGuard;
  • who sets owner and when sets reviewBy; a later block of the same kind that sets a value overwrites the earlier value;
  • applies_to, summary, and evidence are stored as given.

Each rationale or memory emits one rationale or memory fact. The fact records the context type’s own target (RefactorConstraint<fn Editor.mergeAutosaves>), not applies_to. Each protects entry emits a protected_shape fact, and each guard that requires reevaluation emits a guard_requires_reevaluation fact. forbid transform guards, applies_to, owners, review_by dates, status, confidence, and sensitive produce no facts.

lowerReevaluation stores a ReevaluationInfo and emits a reevaluation fact only when the declaration has satisfies. Whether a reevaluation is valid is decided at rule time by reevaluationValidationReasons in checker/derivations.ts. lowerRole keys roles by their local name, and lowerPolicy merges same-named policies, so require approver in any of them applies. Neither emits facts.

Required context is also decided at rule time. emitDerivedFacts emits one context_required fact per obligation a bearer carries, without the trait’s satisfied_by kinds or its description requirement. hasRequiredContext accepts a matching rationale or memory, subject to the trait’s satisfied_by. A reevaluation satisfies guards, never a required-context obligation.

lowerChange in lowering/changes.ts applies one change declaration after every ordinary declaration in every module has been lowered. planChange copies the model with stageModelForChange in checker/change-planning.ts, applies each entry to the copy in source order, and takes before and after snapshots of every guarded target. commitPlannedChange then appends the resulting change events and publishes the staged copy, so the effective model stays untouched until every entry of that change has been staged.

EntryEffect on the modelChange events
add fn C.f …lowers the new function into component Cnone
modify fn C.f …replaces the function with the restated summaryyes
remove fn C.fremoves the functionyes
add <declaration>lowers a resource, trait, component, relation, implementation, binding, attestation, or rulenone
modify <declaration>removes the declaration, then lowers the new one (a modified attestation is only added)for components, resources, and relations
remove <kind> <Name>deletes the declaration from its index (removeRelation also updates incidence)for components, resources, and relations

The events land in model.changeEvents as ChangeTrigger records (shape-domain.ts). For each transition, changeEventsForTransition emits them in a fixed order: target_changed, then shape_trait_removed for each trait the target lost, then description_removed, then transform_applied for each transform label. Change events are not facts. checkGuardedChanges is their only reader, so guards fire only for targets that a change declaration modifies or removes.

The fact list follows function entries but not declaration entries. add fn, modify fn, and remove fn call removeFunctionFacts and, except for remove fn, re-emit the function’s facts with emitFunctionFacts. Declaration-level modify and remove entries update the typed indexes but leave the replaced declaration’s facts in model.facts. After remove component Archive, for example, the component history::Archive fact remains; after modify component RevisionLog { … }, the original component’s facts remain beside the new ones. Rules are unaffected, because they read the indexes. Coverage is unaffected too, because step 4 of the lowering order rebuilds shape-update paths from the final function registry.

  • lowerImplementation stores the path globs, the conforms_to component, and the on_change requirement, and emits implementation, implementation_path, and conforms_to facts. No fact records the on_change requirement.
  • lowerBinding stores the binding and emits binding, binding_when_changed, binding_require_changed, and binding_allow_attest facts.
  • lowerAttestation stores the attestation kind, its path normalized by normalizeShapeSourcePath (which drops a #anchor and a :line or :line-line suffix), its reason, and its provenance, and emits an attestation fact.
  • rebuildShapeUpdatePaths fills model.shapeUpdatePaths from every function’s source and every evidence on a complete effect entry, normalized the same way. It skips functions from generated-AST modules (shouldIgnoreFunctionForCoverage). Each ref also emits a shape_update_for fact.

Lowering never sees the changed-file list. At rule time, checkCoverage reads model.shapeUpdatePaths and model.attestations, and checkBindings reads model.bindings and model.attestations. Neither reads facts. Both count a ref only when its declaring .shape file is in the changed-file list, and an attestation only when its kind, path, and reason are absent from the base model, falling back to the declaring-file rule when there is no base; see Rule Evaluation. The user-facing coverage rules are in Keep the Model Current.

Every fact must be explainable from loaded declarations, their authored or generated-AST origin, or the standard prelude. Lowering takes no check options. repoRoot reaches the lowered model only through the module origins that checkShapeFiles and IncrementalShapeChecker classify before lowering. Every other option affects only rule evaluation.