Skip to content

Workspace Declaration

A chant.workspace.json at the top of a repository makes it a workspace: a directory of members of declared kinds (#2524 D1, D2). Only this file creates a workspace. chant never infers one, and a project without it stays at level 0, where nothing changes (#2525). chant workspace init proposes one from the projects already in the repository, and chant workspace ls lists what it declares.

The repository is the workspace’s database (ws-074, #3158). The declaration, the record files the kinds it names hold and the ledgers on chant/lifecycle are every durable fact about the workspace, and a tool such as hud or studio changes them only through chant’s commands. A tool may keep a cache or an index, never a fact the repository doesn’t have.

The format is JSON so that any reader can list the members without running TypeScript. The JSON Schema ships in @intentius/chant at src/workspace/declaration.schema.json, with the $id https://intentius.io/chant/schemas/workspace/declaration/v1/chant.workspace.schema.json. chant validates the file against it on every read.

This is the declaration the chant repository commits, shortened:

{
"name": "chant",
"schema": 1,
"members": [
{ "name": "docs", "dir": "docs", "kind": "other", "because": "an npm package with no chant project" },
{ "name": "lexicon-aws", "dir": "lexicons/aws", "kind": "other", "because": "an npm workspace package with no chant project" },
{ "name": "core", "dir": "packages/core", "kind": "other", "because": "an npm workspace package with no chant project" },
{ "name": "examples", "kind": "examples", "glob": "examples/*" },
{ "name": "lexicon-examples", "kind": "examples", "glob": "lexicons/*/examples/*" },
{ "name": "fixtures", "kind": "examples", "glob": ["test/forgejo-preview-e2e", "test/leftness"] }
]
}
NameDialect
chant.workspace.jsonstrict JSON
chant.workspace.jsoncJSON with // and /* */ comments and trailing commas

Having both in one directory is an error (declaration-ambiguous). Duplicate keys are an error in either dialect.

chant finds the file by walking up from the current directory to the git root, the first directory holding .git, and it checks the git root too. The nearest declaration wins, so a command inside a nested workspace reads the inner one. Outside a git repository only the current directory is checked. The walk that finds a project’s chant.config.ts is unchanged and never looks at the declaration.

FieldRequiredValue
nameyesThe workspace’s name, in the name grammar. With a git revision it identifies the workspace to readers.
schemayes1, the only format version so far.
membersyesAn array of members and example groups, in the order readers list them.
minReadernoThe oldest chant version that may read the file, such as "0.81.0". An older chant refuses it with reader-too-old.
pinsnoAn array of pins.
checksnoSeverities for the declaration checks, keyed by WSP id: error, warning, info or off.
recordsnoThe workspace’s own record kinds, each { "kind": "<path>" } with the path from the workspace root. It was added in chant 0.86.0.
hostsnoThe hosts boxes run on, each with the port range its boxes share. It was added in chant 0.91.0.
diagramsnoThe workspace’s own diagram artifacts, each pinned to the renderer its render was made with. It was added in chant 0.91.0.
changesnoThe forward coverage check of chant workspace check --changes: severity (off, warn or fail, default warn) and ignore, globs over file paths from the workspace root whose changes need no record, such as lockfiles and generated output. It was added in chant 0.92.0.
writeScopenoWho may write what: for each principal class, the members whose files it may write and the record kinds it may write, with which verbs. See Write scope and agent sessions. It was added in chant 0.101.0.
agentsnoThe agent sessions, each bound to one member or several. See Write scope and agent sessions. It was added in chant 0.101.0, and a session’s members in chant 0.105.0.
identitynoWho a person-attributed record or gate approval may name, and which gates pass only on a signed approval. See Identity. It was added in chant 0.102.0.
cinoWhich of the branch’s commits passed CI, as green. See CI. It was added in chant 0.108.0.
quorumnoHow many agree verdicts besides the decider’s a record needs, as an integer from 0. chant workspace records reports each record’s quorum against it, and it applies to every record kind with a reviews list. The default is 2. It was added in chant 0.86.0, so a declaration that sets it should set minReader to "0.86.0".
$schema, $commentnoEditor hints and notes. chant ignores them.
x-...noAny field whose name starts with x-, for people and other tools. Allowed on every object in the file. status --json and ls --json print them back, as written, on the object of their output that the declaration holds them on (#3595), so a tool reads what it wrote without parsing the file.

Any other field is refused. A field added later within schema 1 is optional. A declaration that uses one should set minReader to the first chant that knows it, so an older chant reports reader-too-old and doesn’t misread the file.

A member is an object with name, dir and kind.

FieldRequiredValue
nameyesThe member’s name, in the name grammar. Frozen once the member deploys.
diryesIts directory relative to the workspace root, with / separators. "." is the root member.
kindyesHow to read it: chant, workspace, design, other, or a kind a pinned package supplies.
rolesnoRoles the member plays. Each is a role name, or { "name": "...", "path": "..." } with a path inside the member.
generatednoThe files a command writes in the member, with the command that writes each (see Generated files).
linksnoThe member links this member reads, each { "member": "...", "output": "..." }.
outputsnoFor a member of kind other, design or a kind from a package, the outputs it exposes to links. A chant member’s outputs are read from its source instead.
fieldsnoValues for the fields its kind declares, such as an app member’s health path (#3151). Each is a string, integer or boolean, or an object of those for a field the kind declares as an object. A field left out takes the kind’s default. chant workspace check fails a field the kind doesn’t declare, or a value of the wrong type, with WSP005. It was added in chant 0.103.0.
recordsnoThe record kinds the member holds, each { "kind": "<path>" } with the path from the member’s directory. It was added in chant 0.86.0.
diagramsnoThe diagram artifacts this member holds. It was added in chant 0.91.0.
boxnoThe member is a box, or holds a box’s declarations: the capabilities it reaches through a broker, each with its broker and scope. It was added in chant 0.91.0.
upstreamnoWhere an embedded member’s files come from. Versions live in the lock, not here.
becausefor otherWhy the member is there.
travelnotrue when the member goes with an export. chant workspace export takes only members that set it. It was added in chant 0.101.0.
suppressnoDeclaration checks turned off for this member, each { "check": "WSP009", "because": "..." }. See severity and suppression.

Three kinds are built in.

KindThe directory holds
chanta chant project, with chant.config.ts or chant.config.json directly in it
workspacea nested workspace with its own declaration. The outer workspace never runs commands or writes inside it, except chant workspace export into a member with the role export. chant workspace graph reads it read-only, through its own chant workspace graph
otheranything chant does not read. because is required

A pinned package can supply more kinds, as data at its ./workspace-kinds subpath. Workspace Kinds describes the format, which packages are read, and how chant decides when several kinds’ probes claim one directory. A member whose kind no built-in or pinned package supplies is listed with the reason unknown-kind, and chant workspace check fails on it.

A member’s directory leaves the root project, and discovery at the root skips it (#2527). The member’s own commands cover it, and chant workspace build runs them for every member (#2537). chant workspace init lists these directories before it writes.

A member link says that one member reads an output of another (#2524 D6, #2539, ws-008). The consumer states it once and names the producer and the output.

{
"name": "acme",
"schema": 1,
"members": [
{ "name": "shared", "dir": "infra/shared", "kind": "chant" },
{ "name": "web", "dir": "services/web", "kind": "chant", "links": [{ "member": "shared", "output": "ClusterArn" }] },
{ "name": "legacy", "dir": "legacy", "kind": "other", "because": "a queue created by hand", "outputs": ["QueueUrl"] }
]
}
FieldRequiredValue
memberyesThe producer, another member of this workspace. An example group can’t be one.
outputyesThe producer’s output. It is matched exactly, the way deploy matches it.
kindnoThe link kind: output, the default, or telemetry (below). Any other value fails closed.
protocolnoFor a telemetry link, the OTLP protocol the consumer sends: grpc, http/protobuf or http/json. Any other link kind refuses it (WSP098).

The outputs a producer exposes depend on its kind.

KindExposes
chantThe names its source passes to output(ref, "Name"), and each export const name = stackOutput(ref). chant reads the TypeScript without running it.
workspaceNothing. Links name the outer workspace’s own members, and a nested workspace’s links stay its own.
otherWhat the entry lists in outputs.
a kind from a packageWhat the kind lists in its outputs, plus what the entry lists.

chant workspace check resolves every link in source and never reaches the network. A link whose output the producer no longer exposes fails, so renaming an output fails the check for every consumer that links to it. When chant can’t read all of a chant producer’s outputs without running its code, such as an output whose name is computed, a link to a name it didn’t find is kept and reported as unresolved.

Joins nobody wrote down are inferred. Each parameter of a chant member, written export const name = new Parameter(...), is matched to the other members’ outputs by the core function joinKey(). That function ignores case and punctuation. An inferred join is labelled exact when the names are equal and folded when they differ only in case or punctuation, so clusterArn joins ClusterArn as folded. A parameter that matches outputs of two producers is reported as ambiguous and joins neither. A declared link from the same consumer to one of the matched outputs replaces the inferred join, and writing one down is how an inferred join becomes declared.

A telemetry link says that a member sends its telemetry to the collector of another (#2558, D22 of #2524, ws-060). The service is the consumer and the member that declares the collector is the producer. output names a pipeline id (traces, traces/backend) or an exporter id (otlphttp/backend) in the producer’s collector, which the otel lexicon reports for chant workspace graph (#2559).

{ "name": "app", "dir": "app", "kind": "other", "because": "...", "links": [{ "member": "delivery", "output": "traces", "kind": "telemetry", "protocol": "http/protobuf" }] }

The graph command, chant workspace graph, checks the link in its links section and gives it one of four statuses. It is resolved when the producer’s collector has that pipeline or exporter. It is missing when the collector doesn’t, or when the producer declares no collector. With a protocol, a pipeline must have a receiver that speaks it and an exporter must speak it itself, and a mismatch is invalid with a reason that lists what the target speaks. A target whose definition states no protocols leaves the link unresolved. A resolved row also says whether the target is a pipeline or an exporter.

Because chant workspace check reads source and runs no member, it can’t read a collector, and so a telemetry link stays unresolved there (WSP094) while the graph resolves it. A declaration that uses a telemetry link sets minReader to 0.101.0 or newer, since an older chant refuses the link kind.

The declaration names the workspace’s record kinds, so a reader never has to guess which kind files exist (#2680). A member lists the kinds it holds in its records. Kinds whose records belong to no one member, such as a decisions directory at the root, go in the top-level records. The reference workspace declares its decision kind that way, and its design member declares the review-session kind with the design kinds of #3148. In this example the design member holds a kind of its own too:

{
"name": "studio",
"schema": 1,
"minReader": "0.86.0",
"members": [
{ "name": "design", "dir": "design", "kind": "design", "records": [{ "kind": "notes/note.kind.mjs", "name": "notes" }] }
],
"records": [{ "kind": "decisions/decision.kind.mjs" }]
}
FieldRequiredValue
kindyesThe kind file: a module that exports recordKind (Record kinds). The path is relative to the member’s directory, or to the workspace root in the top-level list, and it stays inside it. A path is declared once across the file.
namenoThe name readers show for the kind, in the kind-name grammar. Without it, the kind file’s own recordKind.name. A name is given once across the file.

Readers take the top-level list first. The members’ lists follow in file order.

CommandUses the declared kinds
chant workspace lslists them, with each kind file’s name, or the reason it can’t be loaded
chant workspace checkfails with WSP115 when a kind file is missing or doesn’t load as a record kind. The check is fixed, so it can’t be turned down or suppressed
chant workspace recordsreads every one of them when --kind is not given
chant workspace graph --intentreads every one of them, and runs each one’s commit joins, when --kind is not given

--kind still overrides the declaration in records and graph --intent. A kind file is looked for in the tree read, which is the revision under --at. It is loaded from the working tree, the way a --kind file is. A declaration that uses records should set minReader to "0.86.0". An older chant then reports reader-too-old, where it would otherwise refuse the field as unknown.

writeScope says who may write what, per member and per record kind, for each principal class (#2548, ws-067). agents names the agent sessions, each bound to one member or several (ws-101). Without them nothing is restricted.

{
"name": "studio",
"schema": 1,
"minReader": "0.101.0",
"members": [
{ "name": "app", "dir": "app", "kind": "other", "because": "a Node server" },
{ "name": "design", "dir": "design", "kind": "design", "records": [{ "kind": "sessions/session.kind.mjs" }] }
],
"records": [{ "kind": "decisions/decision.kind.mjs" }],
"writeScope": {
"agent": { "records": { "decision": ["new", "review"] } },
"runner": { "members": ["app"], "records": { "decision": ["review"] } }
},
"agents": [{ "name": "app", "member": "app", "principals": ["app-bot@example.com"] }]
}

writeScope has an entry for each class it restricts: human, agent, runner, service, or a domain class a pinned package supplies, such as reviewer (#3080, see principal classes from a package). A class with no entry isn’t restricted. A declaration that names a domain class sets minReader to 0.102.0 or newer.

FieldValue
membersThe members whose files the class may write, by name, or "*" for every path. Without it, every path. A path in no member, such as chant.workspace.json at the root, is in scope only under "*". The agent entry takes no members: a session writes only the members it is bound to.
recordsKind names, as the kind file’s recordKind.name or the name the declaration gives it, each with the verbs the class may write it with: new, amend, review and close. A kind left out can’t be written at all. Without records, every kind in reach with every verb.
protectedPaths the class may not write, even inside a member it may. Each entry is a glob from the workspace root, or { "path", "except" } for a JSON file the class may change only in what except names: a top-level key, or a JSON Pointer starting with /, where a * token matches every key or array index at its level (#3308). An entry covers the files it matches and everything under a directory it matches, so "box" covers box/ops/guard.mjs. The agent entry takes it too. Added in chant 0.102.0 (#3146).

A kind a member declares is in reach when that member is. The workspace’s own kinds, from the top-level records, are in every writer’s reach, so an agent bound to app can propose a decision that lives at the root. A record is never deleted under a restricted scope.

protected is how a factory keeps a build away from what it must not change (#3146, ws-077). A builder bound to the root member could otherwise write the declaration, the box’s own Ops and the release config:

"writeScope": {
"agent": {
"records": { "work": ["new", "amend"], "proposal": ["new"] },
"protected": ["steward", "delivery/.chant", "delivery/chant.config.ts", { "path": "chant.workspace.json", "except": ["diagrams"] }]
}
}

A record file is judged by records, never by protected: leave a record kind out of records, or narrow its verbs, to keep a builder off it. check --changes reports a write to a protected path with write-scope-protected. For an entry with except, it parses the file before and after the commit, and the change is in scope when the two differ only in those top-level keys and the values those pointers match. { "path": "chant.workspace.json", "except": ["/members/*/box/listing"] } keeps the declaration protected and lets a box’s listing change, which is what chant workspace box listing set writes. chant workspace agent --json lists a session’s protected paths, so a factory’s guard can apply the same list to a build’s diff before anything is committed.

Each entry in agents is a session:

FieldRequiredValue
nameyesThe session’s name, unique among agents, in the name grammar.
memberone of member and membersThe one member the session is bound to: a declared member, not an example group.
membersone of member and membersThe members the session is bound to, when it writes in more than one, each a declared member. Added in chant 0.105.0 (#3505).
principalsnoPrincipals that write only as this session. A commit attested by one, or a write whose by names one, is judged as the session. A principal is listed by one session at most.

A writer’s class comes from the role grants in the trust policy at base. A principal holding the agent role is in the agent class. One holding runner is a runner and one holding service a service, tried in that order. Then each domain class the pins supply is tried, in pin order, by the role it names. Anyone else is human. While writeScope names a class no pinned package supplies, a writer judged human might be in it, so its writes are refused with write-scope-class-unknown. A write that names a session is in the agent class. Naming a session only ever narrows what the writer may do, so chant honours a session name it can’t verify.

WhereHow the session is namedWhat happens outside scope
chant workspace records new, amend, review and close, and the MCP record toolsthe CHANT_AGENT environment variable, set by whatever runs the agentrefused with write-scope-member, write-scope-kind, write-scope-class-unknown or agent-unknown; nothing is written
chant workspace check --changesa Chant-Agent: <name> commit trailer, or an attested principal a session listsa finding for each path, and the check fails

All three read writeScope and agents from the declaration at base (origin/HEAD, main or master, or the range’s base for check --changes), with the trust policy read there too. An agent can’t widen its own scope by editing its copy of the declaration, and an edit to the declaration by a writer restricted to members is itself out of scope. On a developer machine this detects; run in CI with an attestation policy at base, the principal is the signer the policy trusts (ws-002).

A session bound to several members may write the files of any of them and the records of kinds any of them or the workspace declares; its scope is their union, and writeScope.agent applies to all of it. A factory whose builds write an app and also record their evidence in a design member declares one session over both (arugula-salad/studio#402), where a session bound to app alone would fail every build with write-scope-member:

"agents": [
{ "name": "factory", "members": ["app", "design"] },
{ "name": "planter-chat", "member": "app" }
]

A declaration that uses members needs a minReader of 0.105.0 or higher, since an older chant does not know the field. chant workspace agent <name> prints what a session reloads from: its members, its scope and the spec. chant workspace ls --json lists the sessions bound to each member in its agents, read from the same declaration, so a tool that starts an agent for a member reads the names there (#3615).

A record’s by, decided_by, reviewer or answerer, and a gate approval’s --actor, name a person. chant never signs a person in (ws-052). The surface that does (hud, studio’s planter, behold) maps the session to one of these principals and passes it to chant’s write commands (#3163, ws-080):

FormWritten asWhat vouches for it
forge identitygithub:<login>, gitlab:<login>, or <forge>@<host>:<login> for forgejo, gitea or a self-hosted github or gitlab, such as forgejo@codeberg.org:aliceThe surface that signed the person in through the forge. chant records it as given, folding case.
signera principal .chant/allowed_signers lists at base, often the person’s forge identityA seal made with --sign by a key listed for that principal, verified against the signers at base.
role holdera principal holding the agent, runner or service role at base, or listed by a declared agent sessionThe role grant at base. Not a person.
nameanything else, such as a hud roster name or $USERNothing.

The role grants in .chant/trust.json and the signers file name people by the same string, so github:alice is one person in a record, in the key list and in a principal class.

"identity": {
"attribution": "identified",
"gates": {
"ship": { "class": "operator" },
"rollback": {}
}
}
FieldValue
attributionany, the default, takes any name. identified refuses a name: records new, amend and review, points answer and chant approve refuse a --by, author field, answerer or --actor that is not a forge identity, a signer or a role holder, with principal-unidentified.
gatesGates by name. A gate of that name, in any member, Op or component, counts only an approval sealed with chant approve --sign by a key the signers file at base lists for its approver. With class, the approver must also be in that class, read from the role grants at base. A class no pinned package supplies lets nothing through. Without a signers file at base, nothing passes.

Both are read from the declaration at base, so a change can’t loosen its own rule. A run, chant workspace status and chant approve all apply gates. chant approve refuses an approval the rule would not count before writing it, and a run or status leaves out any such approval already on the ledger, so a hud invite link alone can’t clear the gate. status --json marks such a gate with signed.

An approval for a gate under gates is made like this, in the member’s directory, with a key the signers file lists for github:alice:

Terminal window
chant approve web ship --env prod --plan <digest> --actor github:alice --sign ~/.ssh/id_ed25519

The seal is an ssh signature in the chant-gate namespace over the op or component, the gate, the environment, the plan digest, the approver and the time, so it can’t be moved to another plan or gate.

ci.green says which commits passed CI (#3573, ws-103). chant ci tick tags each passing commit, and people and tools read the newest one from git with chant ci last-green, without asking the forge.

"ci": {
"green": {
"branch": "main",
"window": "24h",
"phases": {
"lint": ["lint"],
"build": ["build", "clippy"],
"e2e": ["e2e (*)"],
"macos": { "runs": ["macos (test)", "macos (webkit)"], "skipped": "pass" }
},
"require": ["lint", "build", "e2e", "macos"]
}
}
FieldRequiredValue
branchyesThe branch whose first-parent commits are tagged.
windownoHow far back a tick looks, by committer time, as minutes, hours or days such as 90m or 7d. The default is 24h.
phasesyesPhases by name. Each is a list of check-run name patterns, or { "runs": [...], "skipped": "pass" }. In a pattern, * stands for any run of characters, and the pattern must match the whole name.
requireyesThe phases a commit must pass to be tagged green. Each must be declared under phases.

A phase passes when every check run its patterns match finished with success, judged by each run’s latest attempt. A run that finished as skipped passes only in a phase with "skipped": "pass", which suits a job that a path filter runs on some commits only. The default, fail, never lets a skipped run pass. A run still queued or in progress, or a pattern that no run matches yet, leaves the commit undecided. A commit is green when every required phase passes, and it fails as soon as one of them fails.

The result is kept as two kinds of annotated tag. chant makes each one once, and never moves or deletes either.

TagMade whenIts annotation
ci/green/<sha>a commit passes every required phasethe time, and each required phase’s runs
ci/revoked/<sha>a commit with a green tag later fails a required phase, as a re-run that went red doesthe phase, the run that failed, and the time

A revoked commit doesn’t go green again by itself. Deleting its ci/revoked/<sha> tag by hand makes it count as green again, since its green tag is still there.

The declaration names the workspace’s diagram artifacts, so hud and other readers can list and draw them without guessing which file is a diagram’s source and which is its render, or which renderer made it (#2764). Studio’s docs/diagrams/*.d2 rendered by docs/diagrams/render.sh is the motivating case: a .d2 source beside the .svg render.sh writes, pinned to an exact D2 release and a fixed set of flags. Like record kinds, a diagram that belongs to no one member goes in the top-level diagrams, and a member names the ones it holds in its own diagrams. chant never runs the renderer, on any command (ws-052): it only records the pin, so a reader that has that renderer installed can reproduce the render, and chant workspace check can compare a content hash instead of re-rendering.

{
"name": "studio",
"schema": 1,
"minReader": "0.91.0",
"members": [
{ "name": "docs", "dir": "docs", "kind": "other", "because": "the docs site" }
],
"diagrams": [
{
"name": "architecture",
"title": "Studio architecture",
"source": "docs/diagrams/architecture.d2",
"render": "docs/diagrams/architecture.svg",
"renderer": { "tool": "d2", "version": "0.9.0", "args": ["--layout=elk", "--theme=0", "--pad=40", "--omit-version"] },
"sourceHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
]
}
FieldRequiredValue
nameyesThe diagram’s name, in the name grammar. Unique across the declaration.
titleyesShown above the diagram.
sourcenoThe source file, from the workspace root, with / separators. Null, the default, for an SVG with no source.
renderyes, except for mermaid and excalidrawThe rendered image, from the workspace root, with / separators. A mermaid or excalidraw diagram may leave it out or set it to null.
rendereryesThe renderer the render was made with: tool (d2, mermaid, graphviz or excalidraw), version (the exact release, such as "0.9.0") and args, the arguments passed before the input and output paths. For mermaid and excalidraw, version is the release of the library the reader draws with, such as "11.4.1".
sourceHashnosha256 hex of the source’s bytes when the render was last produced from it. With it, chant workspace check fails (WSP133) when the source’s bytes now hash to something else: the render is stale for its source. Without it, or without a source, the render is never checked for drift. For a mermaid or excalidraw diagram with no render, it pins the source itself.

mermaid and excalidraw diagrams are drawn by the reader. Each needs a source and may name no render: hud draws a .mmd source with the mermaid release renderer.version names, and opens an .excalidraw file (Excalidraw’s JSON scene format) as an editable canvas. An excalidraw entry may still name an exported SVG for pages that can’t draw the scene. For either tool, sourceHash pins the source itself. Declarations with a render-less entry need chant 0.96.0 or newer to read them, and should set minReader to match.

{
"name": "request-flow",
"title": "Request flow",
"source": "docs/diagrams/request-flow.mmd",
"renderer": { "tool": "mermaid", "version": "11.4.1" }
}
{
"name": "whiteboard",
"title": "Whiteboard",
"source": "docs/diagrams/whiteboard.excalidraw",
"renderer": { "tool": "excalidraw", "version": "0.18.0" }
}

Unlike a generated file or a record kind, a diagram’s source and render are workspace-root-relative wherever they are declared: a diagram’s files need not sit inside the directory of the member that declares it.

CommandUses the declared diagrams
chant workspace lslists every one, flattened across the workspace and its members into one diagrams array, each with the member that declares it
chant workspace checkfails with WSP131 when a named source doesn’t exist, WSP132 when a named render doesn’t exist, and, when the entry records a sourceHash, WSP133 when the source’s hash no longer matches it

The example above sets minReader for that reason: without it, a chant older than 0.91.0 meets an unknown diagrams field and refuses the whole file, rather than naming the version gap with reader-too-old.

A box holds no credential (#2726). Each capability it needs from outside, such as inference, Fountain or a third-party API, goes through a broker. The broker is a runtime, such as a lobby or a door. It holds the real credential, swaps the box’s own token for it, and allows only what the declared scope allows. chant never runs a broker (ws-052). The declaration states the capabilities and their scopes, chant workspace check checks that the box holds no credential, and the broker reads the scopes from chant workspace status --json. What a broker answers for each capability, and the suite that checks one, is the broker protocol (#3164).

The member that holds the box’s declarations carries a box block:

{
"name": "chaff",
"schema": 1,
"minReader": "0.91.0",
"members": [
{
"name": "spec",
"dir": "spec",
"kind": "chant",
"box": {
"capabilities": [
{ "name": "fountain", "broker": "lobby", "scope": ["agent", "vault", "conversations", "sandboxes"] },
{ "name": "inference", "broker": "lobby", "scope": ["agent"] }
]
}
}
]
}
FieldRequiredValue
capabilitiesnoEach capability the box reaches outside itself. None when omitted.
intentnoThe id of the decision record that says what the box is for. See Intent.
servicesnoThe services the box runs under its supervisor. See Services.
factorynoWhat the box builds, how a build is checked, which member declares the builder agents and where a finished build is published. See Factory.
listingnoWhat the box shows of itself on a home site that lists boxes. See Listing.
publishernoThe command that publishes the box’s work, which an orchestrator such as studio supplies. See Publisher.
shipnoHow the box ships its staged work to its own site: the Op, its gate, the environment it records releases in, and the bookkeeping paths. See Ship.
capabilities[].nameyesThe capability, in the kind-name grammar, such as inference or fountain. A box names each capability once.
capabilities[].brokernoWhat brokers it, such as lobby or door. It may be a member of this workspace or a runtime outside it, as a planted box’s lobby is. Without it the capability is unbrokered, and chant workspace check fails with WSP122.
capabilities[].scopenoThe parts of the box’s own that the broker lets it reach. The broker enforces the words, and chant records them. For Fountain these are agent (start conversations with its own agent), vault (attach its own vault), and conversations and sandboxes (its own).

A box built from the fountain lexicon’s Box also declares { "name": "fountain-callback", "broker": "fountain", "scope": ["owner"] } (#2780). The pinned fountain spec gives a persistent sandbox a callback token scoped to its owner, and has no way to turn it off for one (managoat/fountain#2497). The entry records that token, so the declaration doesn’t claim a box with no credential while the machine holds one, and it sits beside any fountain capability a lobby brokers for the same box.

The block goes in the workspace declaration and not in the box’s TypeScript. check and status read the declaration without running member code, and a broker reads the read contract. A lexicon’s own box type, such as the fountain lexicon’s Box composite or chaff’s Box, keeps describing the machine. The declaration adds what the box may reach and through what.

CommandUses the box block
chant workspace checkreads every file in the member’s directory and fails with WSP121 (box-credential-declared) on a literal secret. It fails with WSP122 (box-capability-unbrokered) on a capability that names no broker, and with WSP125 (box-fountain-callback-undeclared) when a member that builds a fountain Box doesn’t declare fountain-callback. It fails with WSP126 (box-intent-unknown) when the intent names no decision record, and warns with WSP127 (box-intent-unconstrained) when that record’s constrains names no member or path of this workspace at all
chant workspace status --jsonlists each member’s box, with every capability’s name, broker and scope, the box’s intent, its services, its factory, its listing, its publisher and its ship, and whether the workspace is plantable
chant workspace graph --jsonprints each member’s factory and listing as box, and plantable, read at the revision --at names

Set minReader to "0.91.0" or newer when the file has a box block, so an older chant reports reader-too-old instead of an unknown field.

A new box starts as a question, and its first answer is what the box is for (#2850). That answer is a decision record, so every reader of the box sees the same answer: the intent graph and hud as much as the runtime that plants it. The box block names the record’s id in intent, and chant looks for it among the records of every declared record kind named decision:

{
"name": "fern",
"dir": "fern",
"kind": "other",
"because": "a box planted from a question",
"box": { "intent": "box-001" }
}

The record starts proposed with a null choice. It holds the question and its options, and its constrains names member:fern. records new writes it from a JSON file of its fields:

Terminal window
chant workspace records new decisions/decision.kind.mjs --from box-001.json --prefix box --by alex

The person who answers the question decides the record with records amend. The fields given with --set set the choice, decided_by and decided_on, and move the state to decided:

Terminal window
echo '{ "state": "decided", "choice": { "option": "a", "reason": "A review queue for the design team." }, "decided_by": "sam", "decided_on": "2026-09-26" }' \
| chant workspace records amend box-001 --kind decisions/decision.kind.mjs --set -

status --json reports the record under the member’s box.intent as { id, state, question, choice, answer, decided_by, decided_on, approved }, before and after it is decided. answer is the label of the options[] entry choice.option names, or null while none is chosen (#2855). approved says whether the state counts as decided, by the decision kind’s approval ranks, so a reader holds work on it without keeping its own list of states (#3609). check fails when no decision record has the id (WSP126), and warns when the record’s constrains names no member or path of this workspace at all: no member: entry for a declared member and no path: entry at, above or inside one’s directory (WSP127). The member it names need not be the box’s own: a box one member runs can be what a decision naming another member constrains, such as the app the box runs when the box block sits on the box’s steward (#2857). With the box’s own member constrained, graph --intent fern shows the decision for the box’s files. Set minReader to "0.94.0" or newer when a box block has an intent.

A box runs its processes as services under a supervisor: sprite-env on a sprite, or a stand-in with the same command line elsewhere. The box block lists them in services (#2880), so a reader of status --json has them before anything observes the box, and the fly lexicon keeps them running from the same list:

{
"name": "box",
"dir": "box",
"kind": "other",
"because": "the box's steward and its Ops",
"box": {
"services": [
{ "name": "app", "cmd": "${HOME}/box/run-app.sh", "duration": "3s", "health": "http://127.0.0.1:5173/health" },
{ "name": "door", "cmd": "${HOME}/box/run-door.sh", "needs": ["app"], "httpPort": 8080, "duration": "2s" },
{ "name": "site", "cmd": "${HOME}/box/run-site.sh", "health": "http://127.0.0.1:3300/health", "optional": true }
]
}
}
FieldRequiredValue
services[].nameyesThe service’s name in the supervisor, in the kind-name grammar. A box names each service once.
services[].cmdyesThe command the supervisor runs. It is split on whitespace, with no quoting or other shell syntax: the first word is the executable (sprite-env services create --cmd) and the rest its arguments (--args, comma-separated, so an argument can’t hold a comma). Each ${VAR} in it is expanded from the environment of the process that applies the list, word by word, so the declaration holds no machine path. A command that needs a shell belongs in a script.
services[].needsnoServices of the same block that start first (--needs).
services[].httpPortnoThe port the supervisor routes the sprite’s URL to (--http-port). One service of a box at most sets it.
services[].durationnoHow long the service must stay up after a create or start, such as 3s (--duration). Without one, the create passes --no-stream and returns once the service is defined.
services[].healthnoA URL that answers 200 while the service works. Without one, the supervisor’s state decides.
services[].optionalnotrue for a service something else creates by name, such as a site a release Op makes. An apply creates it only when its only names it, and an observer skips it while the supervisor has no such service.

The services also follow rules the schema can’t state. Each needs entry names a service of the same block, and the needs form no cycle. A block gives each name once, and one service at most sets httpPort. A declaration that breaks one can’t be read (declaration-invalid), so check fails with WSP001.

The fly lexicon reads the list with box: true, from the box block of the member whose directory holds the working directory. spriteServicesObserve({ box: true }) observes the services for a ConvergeOp, spriteServiceRestart({ box: true }) restarts one and waits for its health, and spriteApplyServices({ box: true }) creates and replaces them through sprite-env services. A full apply (no only) also deletes each service the supervisor has that the block does not declare, so a service dropped from the list stops running; prune: false keeps them. chant reads services from 0.95.0 on, and a declaration that has them needs minReader of "0.95.0" or higher.

A box’s factory turns a work item into a change: a builder agent edits the members the factory builds, a check gives the verdict, and a finished build is published as a pull request (#3146, ws-077). The factory block declares it, so any orchestrator, a studio or a fountain steward, reads the same facts from status --json:

{
"name": "steward",
"dir": "steward",
"kind": "chant",
"box": {
"services": [{ "name": "app", "cmd": "node ${HOME}/box/app/steward/preview.mjs" }],
"factory": {
"builds": ["studio"],
"check": { "run": "npm run --silent check && npm test --silent", "kind": "test" },
"checks": "checks",
"builders": "steward",
"publish": { "repo": "arugula-salad/studio", "base": "main", "branchPrefix": "box/" }
}
}
}
FieldRequiredValue
buildsyesThe members the factory builds, each a declared member of any kind: an app, a chant member, a Terraform estate. A work item a person asks for constrains member: and the first of them.
checknoThe command whose exit code is a build’s verdict, run from the workspace root after the work item’s own checks: a string, or { "run", "kind" }. kind says what the command is, one of test, build, lint, plan and conformance, and is test for a string.
checksnoThe directory, from the workspace root, where a builder writes the check for its work item. Without it the orchestrator chooses.
buildersnoThe member that declares the builder agents, such as the one whose agents/ holds them.
tiersnoWhich builder agent builds at which tier: a list of { "tier", "agent", "kinds", "session" } (#3152, ws-094). See Builder tiers. Added in chant 0.103.0.
publishnoWhere a finished build goes: { "repo", "base", "branchPrefix", "head", "forge" }. The branch <branchPrefix><work item id> is pushed and a pull request opened on repo (owner/name) against base, the repository’s default branch when omitted. branchPrefix is chant/work/ when omitted, the work lease’s own branch. head is a fork (owner/name) the branch is pushed to instead, so the pull request comes from <head owner>:<branch>; a runtime that publishes from each contributor’s own fork supplies that per contributor. forge is github. A template can’t name the repo of each box planted from it, so it leaves publish out, and whoever plants the box sets it with chant workspace box factory set (#3600).

The factory is opt-in (#3174): a workspace with no factory is held to nothing here. It needs no box services and no app, so an infra workspace can declare one whose builds names its estate members and whose check is a plan, and run it on a fountain steward. There is one factory per declaration, and its builds and builders name only declared members; a second factory or an unknown name stops the read with declaration-invalid, which check reports as WSP001. What a build may not change goes in writeScope.agent.protected.

The reference workspace ships three profiles that show the opt-in at work: ideation declares records and a stub app with no factory, app declares the factory over an app member that runs as the box’s service, and infra declares a factory over an estate member with no app and no box services. chant workspace init --profile copies one.

A work item has a tier, from its own tier field or the slice-tier decision point’s answer, in the vocabulary its work kind declares in work.tier.tiers (work items). The factory’s tiers say which agent builds each tier, so an orchestrator resolves the tier from the spec instead of from metadata only it reads, such as studio’s studio/role and studio/tier on its Agents (#3152).

"factory": {
"builds": ["app", "network"],
"builders": "delivery",
"tiers": [
{ "tier": "small", "agent": "builder-small" },
{ "tier": "medium", "agent": "builder-medium" },
{ "tier": "large", "agent": "builder-large" },
{ "tier": "small", "agent": "infra-small", "kinds": ["terraform"], "session": "infra" }
]
}
FieldRequiredValue
tieryesThe tier, a word of the work kind’s work.tier.tiers.
agentyesThe builder agent’s name, as the builders member declares it, such as a fountain Agent in its agents/.
kindsnoThe member kinds the agent builds at this tier, such as app or terraform. An entry without kinds builds the tier for every kind no other entry of the tier names.
sessionnoA declared agent session the builder writes as, so its write scope is that session’s. An infra builder bound to an estate member edits the estate and leaves applies to the gates.

A tier has at most one entry without kinds and at most one per kind, tiers needs builders, and a session names a declared session; otherwise the read stops with declaration-invalid. For each member in builds and each tier, status --json prints the agent that builds it in builderFor. The entry for the member’s kind wins over the tier’s default.

A box can show itself on a home site that lists boxes, with a title, a line and a cover picture. The listing is configuration of the box, so it lives in the declaration and a change to it is reviewed like any other (#3154, ws-074):

"box": {
"listing": { "published": true, "title": "Fern", "line": "A garden planner for a small allotment", "cover": "box/cover.png" }
}
FieldRequiredValue
publishednoWhether the box is listed. true when omitted: a box is listed unless its owner takes it out.
titlenoOne line of at most 60 characters. "" when omitted, and a reader then shows a name of its own.
linenoOne line of at most 140 characters saying what the box is. "" when omitted.
covernoA PNG, JPEG or WebP picture of the box, as a file from the workspace root. status and graph print its sha256, so a reader can cache it by hash.

A title or line with a control character, such as a newline, can’t be read. Since factory and listing are new in chant 0.102.0, a box block with either needs minReader at "0.102.0" or later.

A tool such as hud changes the listing with chant workspace box listing set, which edits only these fields and copies a cover picture in, rather than editing the declaration itself (#3308).

A box’s work leaves the box through its publisher. The publisher applies a built work item to the box’s checkout and opens a pull request on factory.publish’s repo when one is named. It also sends the records a person kept uncommitted. The orchestrator does that work, so the box block names the command it supplies (#3165, ws-088):

"box": {
"publisher": "node box/ops/factory/publish.mjs"
}

chant splits the command into words as a shell splits plain words, where quotes group and a backslash escapes. No shell runs it, so $HOME and ~ reach the program as written. A surface calls chant workspace box publish rather than running the command. chant then runs it from the workspace root with a JSON request on stdin and checks the commit it made for the apply record of ws-075. status --json prints it under the member’s box, or null. A chant older than 0.103.0 refuses the field, so set minReader to "0.103.0" when you add it.

The people working on a box see its working tree at once, and everyone else sees the app as it was last shipped, until a person ships the staged work. ship names the Op in the box member that does it (ws-100):

"box": {
"ship": { "op": "release", "gate": "ship", "env": "box", "bookkeeping": ["decisions", "work", "answers"] }
}
FieldRequiredValue
opyesThe Op in the box member that ships, as chant run takes it. Its own steps commit the staged tree as one commit by the person shipping, with a Chant-Record trailer for each work item it lands, then release that commit to the box’s site, stopping at gate, and record the release in env’s release ledger.
gatenoThe gate the Op stops at for a person’s approval, which is ship when the field is left out.
envnoThe environment whose release ledger, under the box member, the Op records each release in, which is box when the field is left out.
bookkeepingnoPath prefixes from the workspace root whose changes never count as waiting to ship, such as the record directories; with none, every change counts.

chant runs none of those steps itself. A surface offers Ship when the field is set, and it runs chant run <op> in the member’s directory with the person’s principal in CHANT_SHIP_BY, and approves the gate as for any Op. chant workspace status --json prints it under the member’s box, with serving, the latest release in env’s ledger, and pending, the files that differ between that commit and the working tree, bookkeeping left out. A declaration that uses ship needs a minReader of "0.104.0" or later, because older readers refuse the field.

Uncommitted records, work branches and kept attempts sit on a box’s disk until someone commits and pushes. replicate names where chant pushes it, so a box that is lost or reclaimed loses none of it (#3172, ws-085):

"box": {
"replicate": { "remote": "mirror", "refs": ["work", "kept", "wip", "ledger"], "on": ["save", "release"], "every": "10m" }
}
FieldRequiredValue
remotenoThe git remote to push to, by name. origin when omitted. A box holds no credential, so the host adds the remote, such as a bare mirror on the machine, with the credential it brokers.
refsnoWhat goes: work (the branches under chant/work/), kept (refs/chant/kept/...), wip (the snapshots under refs/chant/wip/<branch>, uncommitted records included) and ledger (chant/lifecycle). All four when omitted.
onnoWhen chant pushes on its own: save, after each chant workspace wip save, and release, after a work lease is released. Both when omitted. [] leaves every push to wip push. A record write never pushes, because chant’s record writes never run git.
everynoHow often the host runs chant workspace wip push on a schedule of its own, such as 10m. chant runs no scheduler.

At most one box block declares replicate, since a checkout’s work in progress goes to one remote. chant pushes each ref under its own name and never forces a push. status --json prints the policy under the member’s box, and its top-level replication says how far each ref is from the remote. replicate is read from chant 0.103.0 on, so minReader must be at least "0.103.0" where it is used.

A host that plants a repo as a box, such as a studio’s planter, needs exactly one box to run. status --json and graph --json say whether the workspace is plantable: exactly one member’s box block declares services. plantable.box names that member, and plantable.reason says why not otherwise: box-none or box-several. Being plantable isn’t a check. A records-only workspace, or an infra workspace with a factory and no box services, is a valid workspace that a planter declines.

Several boxes on one machine collide unless something keeps them apart. When two boxes keep hud’s identity in one file, they share one owner (arugula-salad/studio#39). A browser scopes a cookie to a host and not to a port. So when two boxes set one cookie name on one hostname, signing into one signs you out of the other. The box block states the box’s isolation, and chant derives the values from the box’s identity (#2727). A runtime that plants or starts boxes reads them from chant workspace status --json instead of choosing its own.

{
"name": "lobby",
"schema": 1,
"minReader": "0.91.0",
"hosts": [
{ "name": "local", "ports": { "from": 7100, "to": 7999, "perBox": 20 } }
],
"members": [
{
"name": "fern",
"dir": "boxes/fern",
"kind": "chant",
"box": {
"host": "local",
"slot": 0,
"ports": { "app": 0, "door": 1, "proxy": 2, "inject": 3 },
"state": { "HUD_IDENTITY_PATH": "hud/identity.json", "HUD_DB_PATH": "hud/events.db" },
"cookies": ["hud_session", "chaff_door"],
"capabilities": [{ "name": "inference", "broker": "lobby", "scope": ["agent"] }]
}
},
{ "name": "moss", "dir": "boxes/moss", "kind": "chant", "box": { "host": "local", "slot": 1, "ports": { "app": 0, "door": 1 } } }
]
}

A host is one machine reached under one hostname. Everything a box could collide on belongs to its host, so chant workspace check compares boxes with the other boxes on their host and never across hosts. A box that runs on a machine of its own, such as a Fountain sandbox, is the only box on its host.

Host fieldRequiredValue
nameyesThe host’s name, in the name grammar, unique among hosts.
portsyes{ "from", "to", "perBox" }: the range boxes on the host take their ports from, inclusive, and the size of each box’s block.
stateRootnoWhere boxes on the host keep state, starting with an environment reference that the runtime expands on the machine. The default is ${XDG_STATE_HOME}/chant/boxes.
Box block fieldRequiredValue
hostwith slotThe host the box runs on, one of hosts. A block without it declares no isolation.
slotwith hostThe box’s number on its host, from 0. Its ports come from the slot’s block.
portsnoEach port the box listens on, as a name and its offset in the block, from 0 to perBox - 1.
statenoState the box keeps outside its checkout, as a name and a path relative to the box’s state directory. The name is usually the environment variable the runtime sets, such as HUD_IDENTITY_PATH.
cookiesnoThe session cookie names the box’s runtimes set, such as hud_session.

ports, state and cookies need a host. A box’s identity is its host, the member’s name and its slot. The values follow from those and the host’s declaration, and from nothing else:

ValueDerived asfern above
port blockfrom + slot * perBox to from + (slot + 1) * perBox - 17100 to 7119
port pthe block’s first port plus p’s offsetdoor is 7101
state directory<stateRoot>/<member name>${XDG_STATE_HOME}/chant/boxes/fern
state entrythe state directory, then the entry’s pathHUD_IDENTITY_PATH is ${XDG_STATE_HOME}/chant/boxes/fern/hud/identity.json
cookie c<c>_<member name>hud_session_fern

The same declaration gives the same values on every machine. Adding a box changes no other box’s values, and neither does removing or moving one. chant leaves the environment reference in a state path for the runtime to expand. It never reads the machine, and it listens on no port. Distinct names give distinct state paths and cookie names, since a member name has no _, so ports are the value two boxes can share by mistake: two boxes on a host with one slot.

Some mistakes stop the read with declaration-invalid. A box may only name a declared host, and its slot’s block has to end at or before the host’s to. Each offset is below perBox, and a host name is used once. chant workspace check then reports what the boxes resolve to:

IdCodeFails when
WSP123box-isolation-collisiontwo boxes on one host resolve to the same port, state path or cookie name, or two ports in one box share an offset. Two state entries in one box may name one file, as hud and a door that reads hud’s identity do
WSP124box-isolation-literala host’s stateRoot is a literal machine path, such as /Users/..., ~/... or anything under $HOME, or a box’s state entry is absolute, starts with ~ or an environment reference, or has a .. segment

Both are fixed errors, and each finding carries its code.

An entry with "kind": "examples" is an example group (ws-051). It covers projects that exist as examples or test fixtures in one line.

FieldRequiredValue
nameyesThe group’s name, in the name grammar.
kindyesexamples
globyesA glob, or an array of globs, over directories relative to the workspace root
suppressnoDeclaration checks turned off for this group, as for a member

Each directory a glob matches that holds a chant project is built and linted by the workspace commands. A match holds a project when a chant.config.ts or chant.config.json sits in it or up to four levels below it, or when its own package.json depends on @intentius/chant or a chant lexicon. Matches that hold none are left alone.

A group has no ledger, no releases and no links, and its matches are never members of their own. A match may sit inside a member’s directory, as lexicons/aws/examples/lambda-api sits inside the lexicon-aws member. That member’s build and lint then leave the match to the group.

Glob syntaxMatches
*, ?within one path segment
**any number of directories
[...]one character from a set

A wildcard never enters node_modules or a directory whose name starts with ..

A member lists the files a command writes in its generated array (#2541, D14 of #2524). Each entry names the file, the command that regenerates it and, optionally, what the command reads. chant workspace check fails when a listed file differs from what its generator writes, and the lineage lock classes the same files generated.

{
"name": "api",
"dir": "services/api",
"kind": "chant",
"generated": [
{
"path": "ci/pipeline.yml",
"generator": "chant build ci --lexicon github -o ci/pipeline.yml",
"sources": ["services/api/src/pipeline.ts"]
},
{
"path": "skills/chant-aws/SKILL.md",
"generator": "chant update",
"handWritten": { "because": "we trim the skill for this service" }
}
]
}
FieldRequiredValue
pathyesThe file, relative to the member’s directory. It may not sit inside another member’s directory, and a member lists a path once.
generatoryesThe command line that writes the file. It runs in the member’s directory with no shell, so pipes and redirections are refused.
sourcesnoFiles or directories the generator reads, relative to the workspace root, since a generator often reads other members. Each must exist.
handWrittenno{ "because": "..." } marks a file kept by hand. The check reports it with the reason and never runs its generator, and the lock classes it owned.

When the generator passes -o or --output with the entry’s path, the check points that flag at a temporary file. Otherwise the generator writes in place, and the check puts the git working tree back afterwards.

chant registers some files without an entry. The files chant update rewrites are implicit generated entries of every chant member, and an entry for the same path takes their place, which is how a member keeps a skill by hand.

FileClass
skills/*/SKILL.mdgenerated, by chant update
.mcp.jsonseed, written once at init
.chant/types/ignored, since it is gitignored

Root CI files that forges read from fixed paths, such as .github/workflows/, sit outside the member that generates them. Their exemption comes with #2542 (ws-042). Until then an entry’s path stays inside its member.

The workspace, its members and its groups are all named the same way.

RuleDetail
Grammar^[a-z0-9][a-z0-9-]{0,39}$, for member names, group names and the workspace name
Uniqueacross every entry in members, members and groups together
Reserved_workspace and _members, which ledger paths use; the grammar already rules them out

These rules keep every directory owned by one entry at most.

RuleError
A dir or glob stays inside the workspace: no leading /, no . or .. segments, no \declaration-invalid
No two members share a directoryplacement-invalid
Only the root member "." contains other membersplacement-invalid
A group match is never a member’s directory and never contains oneplacement-invalid
A group match never sits inside a nested workspace memberplacement-invalid
No two groups match the same directoryplacement-invalid

The last three depend on what the globs match, so they are checked when the groups are expanded, on every chant workspace ls.

A pin names the toolchain or a plugin that reads the workspace.

ShapeFields
an npm packagepackage and version (an exact version), optional integrity
a local pluginpath inside the workspace, optional integrity

A pin of @intentius/chant names the root’s chant, which is the chant that reads the declaration (which chant reads it). The workspace commands of any other chant version hand themselves to it when it is installed at the root, and refuse with root-chant-required when it isn’t.

integrity is a Subresource Integrity digest such as sha256-.... chant records pins and lists them in chant workspace ls --json. Member kinds come only from pinned packages, which chant reads as data and never imports (Workspace Kinds).

A path pin with integrity is checked whenever chant loads kinds from it: the directory’s content hash must equal integrity, or chant reads nothing from the plugin, and chant workspace check reports WSP002 on the pin with the value it expected and the one it computed (ws-065). chant workspace pin <path> prints the value to put there. A pin with no integrity is not checked.

CoveredLeft out
every regular file under the directory, at any depth, by its bytes and its path from the directoryanything inside a node_modules or .git directory

The value is sha256-, sha384- or sha512- and the base64 of that algorithm over a manifest with one line per file, <hex digest of the file> <path>, sorted by path. A file path is hashed by its bytes. A symbolic link inside the directory makes the check fail, since it can point outside the plugin. The hash is of the working tree, so build output or untracked files inside the directory change it: pin a directory that holds only committed files.

The check covers kinds read from a path pin. It doesn’t cover a package pin’s integrity, which names a registry tarball and is still not recomputed, or the record kind files named in records, which have no integrity field. The declaration is read from the working tree, so a change that edits a plugin and its pin together passes: the pin guards a plugin edited on its own.

chant records each lifecycle fact on the orphan branch chant/lifecycle, and keeps operator leases in refs beside it. A member writes all of them under its own directory on that one branch (#2538, #2524 D7). In the table, <ledger> is the top of the branch for a project with no declaration, and _members/<member>/ for a member. The paths are chant’s storage rather than a contract, so read the facts with the CLI.

StorePath
Release records<ledger><env>/releases.jsonl
State snapshots<ledger><env>/<lexicon>.json, with a <stack>__ prefix for a project with several stacks
Accepted observation baseline<ledger><env>/observation-baseline.json
Op run records<ledger><env>/runs__<op>.jsonl
Converge tick records<ledger><env>/converge.jsonl
Gate facts, pending and resolved<ledger>_gates/<op>.jsonl
Build manifests<ledger>_builds/<digest>.json
Operator leasesthe ref refs/chant/lease/<ledger><op>

chant looks up from a project’s directory to the git root for a declaration. The entry that owns the directory decides the ledger.

The project isIts ledger
a project with no declaration above it (level 0)the top of the branch, byte for byte as before
the root member ".", or a directory no member ownsthe top of the branch
a match of an example group, since a group has no ledgerthe top of the branch
any other member_members/<member>/
a member of a nested workspace held by the outer member platform_members/platform/_members/<member>/

A project with no declaration loads no workspace code for this lookup. When a declaration exists but can’t be read, a ledger write fails with the read error instead of guessing the member. There is still one branch, so pushing it and refusing a stale push work as before. One commit can write the ledgers of several members.

Receipts written by an effect() step are not on the branch. They live wherever the lexicon stores them, named by ownership.stack and environment, so distinct stacks keep members’ receipts apart.

Declaring a project as a member changes where it writes from then on. chant does not copy the records the project already wrote at the top of the branch. A project with history that becomes the root member "." keeps reading it.

The first chant that writes member paths is 0.81.0. A member whose own toolchain resolves an older chant keeps writing the top of the branch. Two checks cover this, and chant workspace check reports them. Neither can be turned down or suppressed.

IdFails when
WSP071two chant members set the same ownership.stack. Markers carry no member name, so the stack keeps members’ resources apart. A member with no stack stamps no marker and isn’t compared. chant workspace init proposes a distinct stack for each member
WSP072two members that write the top of the branch share an environment name, taken from environments and a literal ownership.env. The root member writes there, and so does a member on a chant older than 0.81.0

A declaration that can’t be read is never treated as empty. Every error names the file, and a parse or schema error names its line and column.

declaration-invalid: chant.workspace.json:9:7: unknown field "dependsOn"; only fields named x-... may be added
CodeCause
declaration-missingno declaration between the directory and the git root
declaration-ambiguousboth chant.workspace.json and chant.workspace.jsonc exist
declaration-unparseablenot valid JSON, or not valid JSONC for .jsonc
declaration-invaliddoesn’t match the schema, or repeats a name
placement-invalidbreaks a placement rule
reader-too-oldminReader is newer than this chant
root-chant-requiredthe declaration pins another chant, which isn’t installed at the root

chant workspace ls --json reports the same codes with the location (Output). Every code is in the closed list of the read contract.