Drift Record
A drift record says what one read found for one resource instance (#4451). Each engine’s reads become the same records. On a chant member that means the live diff, deep observe and component status. A stock terraform root gives its state and plan. A choudoufu root gives live-plan, live-ls and its plan. A renderer then colours a card from one pair, (status, read), with no per-engine branches.
The module is @intentius/chant/drift-record, the terraform family’s adapters are @intentius/chant-lexicon-terraform/drift-record, and the Pulumi one is @intentius/chant-lexicon-pulumi/drift-record. Both bundle with the change-set code, under the same guard, described below. The schema ships at src/workspace/drift.schema.json, $id https://intentius.io/chant/schemas/workspace/drift/v1/drift.schema.json. The document is versioned by its drift: "v1" field rather than contract. Within the version, fields are only added.
Change sets keep holding planned changes only. A drift record authorizes nothing and is bound to no gate.
The record
Section titled “The record”| Field | Holds |
|---|---|
member | the workspace member, or a choudoufu estate |
engine | terraform, tofu, choudoufu, chant, warden, sql, pulumi or plunge |
address | the engine’s own address, at instance level: aws_eip.pool[0], a Pulumi URN, a chant entity name |
resourceType | the resource type, when the read reports one |
node | the graph node, by the change set’s rule (graphNodeFor): an instance of a count or for_each block is on <blockNode>[0] or <blockNode>["a"], an instance under an expanded module call is on <root>/module.app[0]/<block>[1], the module key on the call’s segment (#4486). null when the graph holds none. A Pulumi URN is on <member>/<qualified type>::<name>, the URN rule of @intentius/chant/pulumi-urn, and a provider or the root stack on none (#4453), and a plunge URN, also a Pulumi URN, by the same rule |
instanceOf | on a record whose node is an instance node, the block node |
observedAt | RFC 3339, per record |
read | state, plan, refresh, live-read or ownership |
fellBackFrom | the read that was asked for and failed, what RootRead.depth holds when fellBackTo is set |
status | see Statuses |
unobserved | on not-observed only: { reason, detail, queried } |
attributes | on drifted only: { path, before, after, sensitive }, sorted by path |
attributesUnobserved | on in-sync or drifted: presence is known and the attributes are not, as after a failed deep read |
ownership | { verdict, by, markerOnObject, ownedBy, adopt }, with verdict one of owned, foreign, adoptable, unknown and by one of marker, derived, record, cache, state, label |
evidence | { tool, version, command, engineCode }. engineCode keeps the engine’s own token, such as UNOWNED, NEEDS_DISCOVERY or ABSENT |
Statuses
Section titled “Statuses”| Status | Means |
|---|---|
in-sync | present, and nothing the read compares differs |
drifted | present, and something differs |
pending | declared and confirmed absent, never deployed |
missing | recorded or deployed, and now gone |
unclaimed | something live sits at a declared identity without this member’s marker |
undeclared | live or recorded, and not declared |
runtime | live, undeclared and expected, such as a controller’s child |
recorded | in the engine’s state |
not-observed | the read could not say; unobserved.reason says why |
driftRecord builds every record and refuses one that breaks a rule, with a DriftRecordError naming the member, the address, the read, the status and the rule:
- A
stateread isrecorded,pendingornot-observed, neverin-syncordrifted. State says what the last apply recorded, not what the account holds. not-observedalways carries a reason from the observation contract, and is never absence.not-reachedis the reason a stream gives an address it expected and never heard about.- An attribute the planner marked sensitive keeps its path and drops both values. A second pass does the same for any path with a key segment
isSensitiveKeynames, such asdb_passwordortags["api_token"].
In an attribute, before is what was expected and after is what the read found. The expected value comes from the record, a snapshot or the declaration. Values are optional; a card needs only the paths.
The document
Section titled “The document”| Field | Holds |
|---|---|
$schema | the schema’s $id |
drift | "v1" |
chant | the chant that wrote it, when known |
records | sorted by member, address and read, one per (member, address, read); a later record replaces an earlier one |
counts | records per status, every status present |
driftDocumentDigest hashes the records without observedAt and evidence. It is for a WatchOp that keeps its document as evidence in its own run ledger; no gate binds it.
mapDriftToGraph fills node and instanceOf against a member’s graph. rollupDriftByBlock groups records by block node and rolls each block up with core’s rollupInstances over DRIFT_INSTANCE_ROLLUP: not-observed, drifted, missing, unclaimed, undeclared, pending, in-sync, recorded, runtime, first match wins.
Adapters
Section titled “Adapters”| Source | Function | Read | Status |
|---|---|---|---|
chant lifecycle diff --live | liveDiffDriftRecords | live-read | unchanged and newly observed: in-sync. Drifted since snapshot: drifted. Missing: pending. Disappeared: missing. Orphan: undeclared. Runtime children: runtime. Unobserved: not-observed |
| deep observe | deepDiffDriftRecords, mergeDeepDrift | live-read | a changed or absent path: drifted with paths. Laid over the thin read, a deep hole becomes attributesUnobserved and leaves the status alone |
chant components status --live | componentStatusDriftRecords | live-read | reconciled: in-sync. Drifted: drifted. Stale: missing. Unrecorded: drifted when live, pending when not. Unknown: not-observed |
terraform show -json over state | terraformStateDriftRecords | state | in state: recorded. Declared and not in state: pending |
terraform show -json of a plan | terraformPlanDriftRecords | plan | a resource_drift update: drifted. A drift delete: missing. A create with no prior row: pending. A delete with no drift: undeclared. Otherwise in-sync |
choudoufu live-plan -json | choudoufuLivePlanDriftRecords | ownership | bound: in-sync, owned by its source. Unowned and adoptable: unclaimed. ABSENT: pending. Any other omission: not-observed, VERIFIED_NOT_IMPORTABLE as read-failed and OUT_OF_SCOPE as filtered. Foreign: undeclared. Controller held: runtime |
choudoufu live-ls -json | choudoufuLiveLsDriftRecords, choudoufuLiveDriftRecords | ownership | declared: false: undeclared. A gap live-plan did not answer: not-observed |
| live-plan with a plan | choudoufuLivePlusPlanDriftRecords | plan | bound with a planned update or replace: drifted with paths, live-plan’s ownership kept |
pulumi refresh --preview-only --json --target <urn> | pulumiRefreshDriftRecords, one read per URN; pulumiRefreshDocumentRecords for an untargeted refresh | refresh | same: in-sync. update: drifted, a path per detailedDiff key. delete: missing. A refresh step: missing with no new state, drifted when its outputs differ, else in-sync. No step for the target: not-observed, not-reached. A secret-wrapped value is sensitive |
plunge drift --resource <urn> --stream, for a Pulumi member on plunge | plungeDriftStreamRecords | refresh | plunge’s own drift-record v1 stream, read with decodeDriftLines and foldDriftLines; the record for the URN keeps plunge’s status, and node comes from the URN rule. A stream with nothing for the URN: not-observed, not-reached. One that does not decode: not-observed, read-failed |
A member runs on plunge when its config says engine: "plunge" or its Pulumi.yaml names a backend URL starting with plunge (pulumiMemberEngine); chant then runs plunge drift and never pulumi for its drift. refreshPulumiMember in @intentius/chant-lexicon-pulumi/run runs the refresh once per resource of the member’s checked-in stack export, so one failing resource holds back only its own record. The Pulumi shapes were written from Pulumi’s documented --json output and still want checking against a real run (#4453).
An UNOWNED omission with no unowned[] row is not-observed, read-failed, with engineCode: "UNOWNED": nothing in the document says what holds the identity.
Streaming
Section titled “Streaming”A read can stream its records as NDJSON, one object per line, in the layout choudoufu’s live-plan -resource writes. The type field on each line says what it holds.
type | Holds |
|---|---|
drift-start | drift, member, engine, read, and expected, the addresses it will report, each a bare address or { address, node, instanceOf, resourceType }. Optionally resource, fellBackFrom, at and evidence |
drift-record | a record’s own fields. A later line for the same (member, address, read) replaces the earlier one |
drift-settle | drift, member, engine, read, and counts per status (a status with none may be left out). Optionally reads, the engine’s own account of what the read cost, carried and never interpreted, at, and notReached |
At settle, every expected address no record answered, under any read, becomes not-observed with reason not-reached. Between start and settle a renderer shows an unanswered card as reading, which is not a status. encodeDriftLine, decodeDriftLine, decodeDriftLines, driftStart, driftSettle and foldDriftLines read and write the stream.
Reading a member: chant workspace drift
Section titled “Reading a member: chant workspace drift”chant workspace drift --member <name> [--read <state|plan|refresh|live-read|ownership>] [--resource <addr>] [--stack <name>] [--env <env>] [--components] [--stream] [-o <file>]One member’s drift, read by the engine that owns it (behold#995). It is a read-contract command: it changes nothing in the workspace and starts no apply.
| Member kind | --read | What runs |
|---|---|---|
chant | live-read, the only one | the member’s own chant lifecycle diff <env> --live --json, under its toolchain, through liveDiffDriftRecords (with mergeDeepDrift when a lexicon has a deep reader). --components adds chant components status <env> --live --json through componentStatusDriftRecords. Needs --env |
terraform | state (default) or plan | chant lifecycle drift in the member’s reader project (the one workspace graph writes): show -json over state, or plan -input=false -lock=false -out=<tmp> and show -json. A plan that fails falls back to state, with fellBackFrom: "plan" |
choudoufu | ownership (default) or plan | the same, through choudoufu live-plan -json and live-ls -json, or for plan live-plan with a plan beside it (choudoufuLivePlusPlanDriftRecords). A plan that fails falls back to ownership, with fellBackFrom: "live+plan". --resource <addr> runs choudoufu live-plan -resource=<addr> and passes its stream through, decoded and re-encoded with this codec |
pulumi | refresh, the only one | chant lifecycle drift in the member’s reader project: the pulumi lexicon’s readDrift (#4455) runs pulumi refresh --preview-only --json --target <urn> once per resource in the checked-in stack-export.json (pulumiRefreshDriftRecords), or one untargeted refresh with no export. A member on plunge runs plunge drift --resource <urn> --stream per resource instead (plungeDriftStreamRecords). --resource <urn> reads that URN alone, on either engine |
--stack <name> reads that stack’s export for a pulumi member, on either engine. A member of several stacks holds one export per stack in the index store (refs/chant/snapshots, written after each apply through chant), and without --stack the read takes the export the store holds, which is the right one only when the member has a single stack. With --stack the read uses that stack’s export and passes --stack <name> to every refresh or plunge drift, over the member’s declared pulumi.member.stack. When the store holds no export for that stack the document carries error: { code: "no-drift-read" } and a message naming the stack, and nothing runs: the read never falls back to another stack’s export or to the checked-in stack-export.json. On a member of any other kind --stack answers read-unsupported. Without the flag nothing changes.
Any other kind answers the error no-drift-read. Nothing is written in a member: init is never run, so a root that was never initialised reads not-observed with the init line in its detail.
Every record’s node is placed on the member’s source graph by graphNodeFor, the change set’s rule, from the graph chant workspace graph --member <name> reads (served from the per-member cache when the source has not moved). For a terraform or choudoufu member that is <member>/<root>/<block key>, the root being named after the member in its reader project, and an instance of a count or for_each block is on its instance node. A Pulumi or plunge record is placed by the URN rule (driftPlanner), the node chant workspace graph gives the resource in the member’s export. choudoufu names its own estate in member; the command puts the workspace member’s name there.
Without --stream it prints one drift document with the read contract’s head beside it: contract: 1, chant and member. When the read could not run, the document has no records and error: { code, message }, and the command exits 1. The codes are the declaration’s, and member-required, no-drift-read, read-unsupported, resource-unsupported, env-required and command-failed.
With --stream it prints the NDJSON stream: a drift-start, the records and a drift-settle for each read. For a chant member chant writes the start and settle itself around the diff’s records. A read that could not run prints nothing on stdout and says why on stderr, with exit 1.
The member half is chant lifecycle drift [--member <name>] [--read <read>] [--resource <addr>] [--stack <name>], which asks each lexicon’s readDrift (LexiconPlugin.readDrift) and prints its stream. A refusal is one stdout line, {"refused": {code, message}}, and exit 3.
Bundling
Section titled “Bundling”@intentius/chant/drift-record and @intentius/chant-lexicon-terraform/drift-record import only these modules:
- the plan hashing code
- the observation contract’s reason list
- the change-set graph’s node rule
- the instance rollup
- the sensitive-key list
- the plan projection
change-set-bundle.test.ts lists every module in that bundle. It fails if TypeScript or esbuild joins it. It also fails on zod and on chant’s own fold, lint and codegen code.