Skip to content

Language Syntax

Each entry gives a minimal example that parses and passes shp check (the effect candidate example only under shape/generated/ast/), a table of its members, and the constraints that the parser or the checker enforces, linked to the diagnostic each one produces. What the declarations mean is explained on the concept pages that the entries link to. The examples are in the canonical form that shp fmt writes.

module history.log
import shared.resources
resource Revision : AppendOnly

A file holds an optional module line, then any number of import lines, then declarations. An import after a declaration is a parse error. shp fmt sorts imports, and sorts declarations by kind and then by name. Every .shape file the checker loads belongs to one Shape model; which files those are is set by file discovery.

A module name is a dotted path of identifiers, such as history.log. Every declaration belongs to its file’s module, so two modules can both declare Store. Files without a module line share one unnamed module.

References are resolved in this order:

  1. A qualified reference, history.log::Revision, names that module’s declaration directly. The module need not be imported.
  2. An unqualified reference resolves to a declaration in the referencing module first.
  3. Otherwise it resolves to the one imported module that declares the name. Two or more matches are ambiguous <kind>, and none is unknown <kind>.

An unqualified prelude trait name, such as AppendOnly, resolves to the prelude unless the referencing module declares a trait with that name, which then shadows the prelude trait. An import only enables unqualified references; importing a module that no file declares is not an error. A function is referenced as Component.fn or module::Component.fn.

  • Identifiers match [_a-zA-Z][\w_]*: a letter or underscore, then letters, digits, or underscores. They are case-sensitive.
  • Strings are single- or double-quoted, and a backslash escapes the next character. shp fmt rewrites them with double quotes.
  • Comments are // … to the end of the line, or /* … */. shp fmt drops both.
  • Whitespace, including newlines, only separates tokens.

Every keyword of the grammar is reserved and cannot be used as an identifier, including as a module path segment: module experiment.evidence is a parse error. Keywords are lowercase, so capitalized forms such as Summary are ordinary identifiers. The 83 keywords are:

add, allow, applies_to, approver, as, attest, binding, callbacks, calls, candidate, change, complete, component, confidence, conforms_to, connects, coordinated_call, decided_on, description, effect, effects, evidence, except, expects, expires, final, fingerprint, fn, forbid, grants, guards, has, hypercycle, implementation, import, kind, memory, modify, module, observed, on_change, or, outcome, over, owner, owns, path, paths, pin, policy, protects, provides, rationale, reason, reevaluation, relation, remove, require, require_changed, require_context, required, requires, resource, review_by, reviewer, role, roles, rule, satisfied_by, satisfies, sensitive, source, status, storage, summary, trait, transform, unknown, unsafe, when, when_changed, who, why.

A few positions accept specific keywords as values: kind and over accept calls, callbacks, provides, and coordinated_call; a protects entry accepts description; satisfies and satisfied_by accept memory and rationale; and a declaration target accepts its kind keyword.

An effect term is Name or Name<Target>. It appears in effect entries, grants, requires, trait allow, forbid, and require members, rule forbid members, and an effect candidate’s effect.

  • Name is any identifier and is matched literally; the checker gives no effect name a built-in meaning. The editor completes the prelude names Read, Append, Update, Redact, LogicalDelete, HardDelete, Truncate, DropStorage, Export, and Import, and the prelude AppendOnly trait forbids three of them.
  • Target is a resource reference; inside a trait it may be a type parameter, and inside a rule the when subject. In an effect entry it must name a declared resource, otherwise unknown resource.
  • An effect entry without a target skips both the final-forbid check and the grant check.

The check order is explained in Effect Model.

A source reference is tag("path#symbol"). It follows source (in functions, attestations, and effect candidates), evidence, and observed.

  • The tag, such as ts, rust, md, or test, is any identifier and is never validated.
  • The checker neither opens the path nor validates the anchor.
  • For coverage matching it drops the #anchor and any :line or :line-line suffix.
  • Prefer a #symbol anchor, use a file-only reference when no stable symbol exists, and avoid line numbers.

A target is KIND Name, where KIND is fn, component, resource, implementation, rule, or relation. Targets appear in context types (RefactorConstraint<fn Editor.mergeAutosaves>) and in applies_to.

A trait may declare type parameters: <T> or <T: Bound>, separated by commas.

  • require_context uses the bound to choose the target kind: Fn or Function for functions, Component, or Resource. An unbound parameter targets functions.
  • A rule’s when … has trait must have no type parameter, or exactly one bounded by Resource.
  • Bounds are compared case-insensitively, but fn, component, and resource are keywords, so write Fn, Component, and Resource.
  • Any other bound parses and is rejected only where one of these uses applies (invalid require_context, invalid rule).

review_by and decided_on are strings. review_by is compared only when freshness is on, and only when it is a real ISO YYYY-MM-DD date; other values are ignored (Freshness). decided_on (required on reevaluation) and expires (required on unsafe functions) are never parsed as dates.

why, status, confidence, outcome, owner, reviewer, and approver take a single identifier, so status "Unexplained" is a parse error. summary, description, reason, expires, review_by, and decided_on take a string.

The paths blocks of implementation and binding hold quoted globs, matched against repository-relative paths:

  • **/ matches zero or more directories, and ** elsewhere matches any characters, including /.
  • * matches any characters except /.
  • ? matches any single character.
  • Braces and character classes are not supported.
module history
resource Revision : AppendOnly, Persistent {
storage postgres.table("revisions")
}
resource RevisionLogAstAnchor {
fingerprint ast.semantic_subtree_v1("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
}
MemberRequiredFormNotes
NameYesresource NameUnique per module (duplicate resource).
TraitsNo: Trait, TraitEach must resolve (unknown trait).
BodyNo{ … }Holds the two members below.
storageNostorage provider.name("value")Repeatable. The checker ignores it; shp analyze compares source table names with it.
fingerprintNofingerprint provider.name("value")One per provider (duplicate fingerprint). Read by expects and pin.

The prelude supplies the resource traits AppendOnly, whose only members are final forbids of HardDelete<T>, Truncate<T>, and DropStorage<T>, and the markers Persistent, Ephemeral, PII, Secret, Public, External, and Internal, which have no members. See Effect Model.

module history
trait NeedsBoundaryNote<T: Component> {
require_context BoundaryNote<T> satisfied_by rationale
}
trait Sealed<T: Resource> {
forbid final HardDelete<T>
forbid final Truncate<T>
}
resource RevisionArchive : Sealed
MemberCheckedFormNotes
Name and type parameters—trait Name<T: Bound, …>Required name; parameters optional. Unique per module.
forbid finalYesforbid final Effect<T>A final forbid on every resource that bears the trait. A target of <T> or no target applies to the bearing resource; a concrete resource target takes effect only when that resource itself bears the trait, and must be declared.
require_contextYesrequire_context Type<T> [satisfied_by rationale or memory]A context obligation on every bearer of the parameter’s target kind. T must be a declared parameter (invalid require_context). Without satisfied_by, either kind satisfies it.
allow, require, forbidNoallow Effect<T>Parsed and formatted, but not enforced.

Only forbid final and require_context affect checking. A plain forbid does not reject anything, require does not demand an effect, and allow grants nothing; grants come only from a component’s grants. A trait declared with a prelude trait’s name shadows the prelude trait within its module. Final forbids are explained in Effect Model, and context obligations in Design Memory.

module history
resource Revision : AppendOnly
component RevisionLog {
owns Revision
grants Append<Revision>
grants Read<Revision>
fn appendRevision
source ts("src/history/log.ts#appendRevision")
effects complete {
Append<Revision>
evidence ts("src/history/log.ts#appendRevision")
}
fn listRevisions
source ts("src/history/log.ts#listRevisions")
effects complete {
Read<Revision>
evidence ts("src/history/log.ts#listRevisions")
}
}
MemberRequiredFormNotes
NameYescomponent NameUnique per module.
Shape traitsNo: Trait, TraitEach must resolve. The prelude shape traits that apply to components are RefactorSensitive, NonIdiomatic, and TestOnly.
ownsNoowns ResourceRepeatable. The resource must be declared; owns has no other effect on checking.
grantsNogrants Effect<Resource>Repeatable. Matches an effect entry only with the same name and target. The target is not name-checked.
fnNoSee fnRepeatable.

A component body holds only these members. Structural links between components and resources are top-level relation declarations; see Relations and Graph Rules.

A function summary lives inside a component, and its members come in this fixed order:

fn NAME [: Trait, …]
[source REF]
[description [required] "TEXT"]
[unsafe] effects complete { EFFECT [evidence REF] … } | effects unknown
[requires EFFECT | reason "TEXT" | expires "TEXT"] …
module history
resource LegacyRevision
component RevisionLog {
grants Import<LegacyRevision>
fn importLegacyRevisions
source ts("src/history/import.ts#importLegacyRevisions")
description "Copies the legacy revision table into RevisionLog once."
unsafe effects complete {
Import<LegacyRevision>
evidence ts("src/history/import.ts#importLegacyRevisions")
}
expires "2026-12-31"
reason "One-off migration from the legacy store."
requires MigrationWindow
}
MemberRequiredFormNotes
NameYesfn nameUnique within its component; a second fn with the same name silently replaces the first.
Shape traitsNo: Trait, TraitEach must resolve.
sourceNosource REFAt most one. Counts toward coverage.
descriptionNodescription "TEXT" or description required "TEXT"description required "" and a RequiresDescription trait without text fail (missing required description).
unsafeNounsafe before effectsRequires reason, expires, and at least one requires (unsafe effects missing policy metadata).
EffectsYeseffects complete { … } or effects unknownExactly one. Strict checking rejects effects unknown in authored modules (unknown effects). effects complete {} claims no effects.
Effect entryNoEffect<Resource> then optional evidence REFAt most one evidence per entry, which counts toward coverage.
requiresOnly with unsaferequires CapabilityRepeatable. The capability is any effect term and is not name-checked.
reasonOnly with unsafereason "TEXT"
expiresOnly with unsafeexpires "TEXT"Not parsed as a date.

Out-of-order members, a missing effects block, and two effects blocks are parse errors. shp fmt prints unsafe on the effects line, sorts effect entries, and sorts the trailing members by their text. What complete and unknown summaries claim is explained in Effect Model.

module editor
resource Revision
component Editor {
}
component RevisionLog {
}
relation EditorCallsLog {
kind calls
connects Editor -> RevisionLog
roles { Editor as caller, RevisionLog as callee }
}
relation RevisionWritePath {
kind coordinated_call
connects Editor -> RevisionLog -> Revision
summary "Editor writes revisions only through RevisionLog."
}
MemberRequiredFormNotes
NameYesrelation NameUnique per module.
kindYeskind KINDOnce.
connectsYesconnects A -> B [-> …] (ordered) or connects { A, B, … } (unordered)Once. At least two endpoints, enforced by the parser. Endpoints are distinct components or resources.
rolesNoroles { A as role, … }Once. Each name is a connects endpoint with at most one role.
expectsNoexpects Endpoint fingerprint provider.name("value")Repeatable. Pins a resource endpoint’s fingerprint.
summaryNosummary "TEXT"Once. Printed by shp graph and shp explain.

The prelude kinds constrain connects:

KindEndpoints
calls, callbacksExactly two, ordered A -> B.
providesExactly two, ordered, from a component to a resource.
coordinated_callTwo or more, ordered.
Any other identifierAny number, in either form.

An expects endpoint must be one of the relation’s connects endpoints and must be a resource, otherwise invalid relation. When that resource lacks the provider’s fingerprint, or its value differs, the result is stale fingerprint expectation. Every malformed relation is reported as invalid relation, with the reasons listed in its entry. How kinds become traversal steps is explained in Relations and Graph Rules.

module publishing
resource PrivateDraft
resource StoryEndpoint
component Feed {
}
component Publisher {
}
rule NoDraftPurge {
forbid final HardDelete<T>
when T has PII
when T has Persistent
}
rule NoDraftRoute {
forbid path Feed -> PrivateDraft over calls or provides
}
rule NoRuntimeCycle {
forbid hypercycle over calls or callbacks
}
rule publisher_only_story_endpoint {
forbid provides StoryEndpoint except Publisher
}

A rule header takes no type parameters. Every member is optional and repeatable:

MemberFormNotes
whenwhen T has TraitBinds the subject T for final forbids. Repeated clauses on one subject are a conjunction. The trait must resolve. In rules with forbid final, it must also have no type parameter or one Resource-bound parameter, and only one subject may be bound.
forbid finalforbid final Effect<T>A final forbid on every resource that has all the when traits. Needs exactly one subject.
forbidforbid Effect<T>Parsed and formatted, but not enforced.
forbid providesforbid provides Resource [except Component]Rejects every provides relation for the resource from a provider other than the except component, or from any provider without except.
forbid hypercycleforbid hypercycle [over KIND or KIND …]Rejects a directed cycle among relations of the listed kinds, or of every kind without over. Only the four prelude kinds contribute steps.
forbid pathforbid path Source -> Target over KIND [or KIND …]over is required. The endpoints are distinct and the kinds must be calls, callbacks, provides, or coordinated_call.

A rule that breaks these constraints is reported as invalid rule. Only the offending part goes inert: an invalid final-forbid set derives no forbid and an invalid forbid path clause is not evaluated; the rule’s other clauses still run. What each clause rejects is explained in Relations and Graph Rules and, for final forbids, Effect Model.

module history
component RevisionLog {
}
implementation RevisionLogImpl {
paths {
"src/history/**/*.ts"
}
conforms_to RevisionLog
on_change require shape_update
}
MemberRequiredFormNotes
NameYesimplementation NameNot checked for duplicates.
pathsNopaths { "glob" … }Repeatable. See path globs.
conforms_toNoconforms_to ComponentThe component must be declared. Coverage never reads it.
on_changeNoon_change require shape_updateshape_update is the only supported requirement; any other identifier is rejected with invalid implementation, so a typo cannot silently ungovern the paths.

An implementation with on_change require shape_update makes its paths governed. How a changed governed path is covered is described in Keep the Model Current.

module repo
binding CheckerDocs {
when_changed paths {
"packages/shp-checker/src/checker/**/*.ts"
}
require_changed paths {
"docs-site/src/content/docs/reference/diagnostics.md"
}
allow attest docs_not_needed
}
MemberRequiredFormNotes
NameYesbinding NameUnique per module.
when_changedNowhen_changed paths { "glob" … }Repeatable. Without it the binding never triggers.
require_changedNorequire_changed paths { "glob" … }Repeatable.
allow attestNoallow attest KINDRepeatable. Without it, an attestation cannot satisfy the binding.

Only shp check --changed-files enforces bindings. The rule for a satisfied binding is in Keep the Model Current.

module repo
attest docs_not_needed {
source ts("packages/shp-checker/src/checker/rules.ts")
reason "Internal extraction only; no documented behaviour changed."
}
attest no_shape_change {
source ts("src/history/log.ts")
reason "Renamed a local variable; the architecture is unchanged."
}

An attestation is attest KIND { source REF reason "TEXT" }. Both members are required, in that order, and the declaration has no name. KIND is any identifier: coverage accepts only no_shape_change, and a binding accepts the kinds its allow attest lists. An attestation counts only when its source path equals the changed path, ignoring anchor and line suffixes, its reason is not empty, and it is current: with a base model (--base-ref or --base-model), its kind, path, and reason are new relative to the base; without one, its own .shape file is in the changed-file list. An attestation never satisfies a guard. See Keep the Model Current.

module editor
resource Autosave
component Editor {
grants Read<Autosave>
fn mergeAutosaves
effects complete {
Read<Autosave>
}
}
change RefactorMerge {
modify fn Editor.mergeAutosaves
transform ExtractHelper
source ts("src/editor/autosave.ts#mergeAutosaves")
effects complete {
Read<Autosave>
}
}

A change block holds any number of entries:

EntryFormNotes
add fnadd fn Component.fn …The full function members, as in fn.
modify fnmodify fn Component.fn [: Trait, …] [transform Label, …] …Restates the complete function. Shape traits or a description it omits count as removed. transform goes after the shape traits and before source.
remove fnremove fn Component.fn
addadd DECLARATIONA resource, trait, component, relation, implementation, binding, attest, or rule declaration.
modifymodify DECLARATIONThe same kinds. Replaces the declaration with the same name; modify attest adds the attestation, as add does.
removeremove KIND Nameresource, trait, component, relation, implementation, binding, or rule.

The checker applies change blocks after every ordinary declaration, entry by entry, and checks the resulting model. A function target is Component.fn or module::Component.fn; for add fn and modify fn, the component must be declared (unknown component). modify and remove entries on a fn, component, resource, or relation are the only events that trigger guards (guarded shape changed). shp fmt sorts entries by their text, so give each target at most one entry; see shp fmt. Guards and reevaluation are explained in Design Memory.

rationale and memory attach typed context to a declaration target. Their headers name a context type and its target, and both accept the grouped guard blocks described below. What they mean is explained in Design Memory.

module editor
resource Autosave
component Editor {
grants Read<Autosave>
fn mergeAutosaves : PreserveInline
effects complete {
Read<Autosave>
}
}
rationale AutosaveInline : InlineRationale<fn Editor.mergeAutosaves> {
applies_to fn Editor.mergeAutosaves
why CognitiveLocality
summary "Branches stay inline so the merge order is reviewable."
evidence test("editor/autosave.test.ts")
}
MemberRequiredFormNotes
HeaderYesrationale Name : ContextType<KIND Target>Unique per module. The target must exist (invalid context target). Any context type name parses.
applies_toNoapplies_to KIND TargetDefaults to the header’s target; when present, it must be identical (context target mismatch).
whyNowhy IdentifierNot interpreted.
summaryNosummary "TEXT"Not interpreted.
evidenceNoevidence REFRepeatable.
Guard blocksNoprotects, guards, who, whenSee guard blocks.
module editor
resource Autosave
component Editor {
grants Read<Autosave>
fn mergeAutosaves : RefactorSensitive
effects complete {
Read<Autosave>
}
}
memory MergeRefactorConstraint : RefactorConstraint<fn Editor.mergeAutosaves> {
applies_to fn Editor.mergeAutosaves
status Explained
confidence High
sensitive
summary "The sync library sends autosaves out of order, so keep the sort."
who {
owner EditorTeam
}
guards {
on_change require ReEvaluation<Self>
}
observed ts("src/editor/autosave.ts#mergeAutosaves")
}

memory accepts the rationale members except why, plus:

MemberRequiredFormNotes
statusNostatus IdentifierNot interpreted.
confidenceNoconfidence IdentifierNot interpreted.
sensitiveNosensitiveWith a policy { require approver }, a reevaluation that satisfies this memory needs an approver. Only memory accepts it.
observedNoobserved REFRepeatable.

The checker requires none of these members.

Guard members are written only as grouped blocks. This is their canonical syntax:

module editor
component Editor {
fn mergeAutosaves : PreserveInline
description "Branches stay inline so the merge order is reviewable."
effects complete {
}
}
rationale AutosaveInline : InlineRationale<fn Editor.mergeAutosaves> {
who {
owner EditorTeam
}
when {
review_by "2026-08-18"
}
protects {
description,
shape PreserveInline
}
guards {
forbid transform ExtractHelper
on_change require ReEvaluation<Self>
}
}
BlockEntriesNotes
protects { … }Comma-separated: description, shape Trait, or any label [Value]Which entries are detectable, and how that narrows when the guard fires, is in Design Memory.
guards { … }One action per line: on_change require ReEvaluation<Self> or forbid transform LabelReEvaluation<Self> and ReEvaluation are the only requirements that act; any other parses and is inert. forbid transform Label fires only on a modify fn that declares transform Label.
who { … }At most one owner IdentifierShown by shp memory.
when { … }At most one review_by "YYYY-MM-DD"Enforced only under freshness.

A block may be empty. shp fmt writes context members in this order: applies_to; then why (rationale) or status, confidence, and sensitive (memory); then summary, who, when, protects, guards, observed (memory only), and evidence. It sorts the entries within a block and merges repeated blocks of the same kind. When a guard fires, and what satisfies it, is explained in Design Memory.

module editor
component Editor {
fn mergeAutosaves : RefactorSensitive
effects complete {
}
}
role EditorTeam
role Security
policy RequireApprover {
require approver
}
memory MergeRefactorConstraint : RefactorConstraint<fn Editor.mergeAutosaves> {
sensitive
guards {
on_change require ReEvaluation<Self>
}
}
reevaluation MergeRechecked {
satisfies memory editor::MergeRefactorConstraint
outcome Confirmed
summary "Refactor keeps the sort the sync library needs."
reviewer EditorTeam
approver Security
decided_on "2026-06-02"
evidence test("editor/out-of-order-autosaves.test.ts")
}
MemberRequiredFormNotes
NameYesreevaluation NameUnique per module.
satisfiesYessatisfies memory Name or satisfies rationale NameThe context must exist. The name may be module-qualified.
outcomeYesoutcome IdentifierNot interpreted.
summaryYessummary "TEXT"Not blank.
evidenceYesevidence REFAt least one; repeatable.
reviewerYesreviewer IdentifierMust be a declared role once any role exists.
approverWhen required by policyapprover IdentifierRequired when a policy { require approver } exists and the satisfied memory is sensitive. Must be a declared role once any role exists.
decided_onYesdecided_on "TEXT"Presence only; not parsed as a date.

A reevaluation that misses a requirement is reported, one diagnostic per reason, as invalid reevaluation, and satisfies nothing.

module editor
role EditorTeam
policy RequireApprover {
require approver
}

role Name declares a reviewer or approver identity. Roles match by local name from any module, and declaring any role turns on role validation for every reevaluation. policy Name { … } holds zero or more require approver members; one policy with require approver anywhere in the Shape model makes approvers required for reevaluations of sensitive memory. Policies with the same name merge.

module shape.generated.ast.src.history.log
resource Revision
resource RevisionLogAppendRevisionAstAnchor {
fingerprint ast.semantic_subtree_v1("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
}
component RevisionLog {
fn appendRevision
source ts("src/history/log.ts#RevisionLog.appendRevision")
effects unknown
}
effect candidate AppendRevisionCandidate {
fn RevisionLog.appendRevision
effect Append<Revision>
source ts("src/history/log.ts#RevisionLog.appendRevision")
confidence low
pin RevisionLogAppendRevisionAstAnchor fingerprint ast.semantic_subtree_v1("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
}

shp ast writes these declarations. This example passes shp check only at a path under shape/generated/ast/, where the generated-AST exemption allows effects unknown. An effect candidate is a hint, not a reviewed effect claim: it grants and forbids nothing.

MemberRequiredFormNotes
NameYeseffect candidate NameUnique per module.
fnYes, oncefn Component.fnThe function must be declared.
effectYes, onceeffect Effect<Resource>The target must be declared.
sourceYes, oncesource REF
confidenceYes, onceconfidence Identifier
pinYes, oncepin Resource fingerprint provider.name("value")Must match that resource’s fingerprint (stale candidate effect pin).

A missing or repeated member is reported as invalid candidate effect. How drafts are generated and promoted is described in Generate Drafts from Source.