Skip to content

edikt migrate ​

Two migrations live under edikt migrate. sidecars lifts legacy in-body directive blocks into co-located sidecars; to-v2 rewrites v1 single-anchor sidecars into the v2 multi-anchor shape. Both are one-shot and idempotent, and /edikt:gov:compile refuses to run until each has been applied.

edikt migrate sidecars ​

One-shot, mandatory migration to the v0.6.0 sidecar format. Reads every governance .md (ADRs, Invariant Records, guidelines) and structurally strips the in-body [edikt:directives:start] block into a co-located <artifact>.edikt.yaml sidecar. The sidecar is a skeleton — topic: needs-extraction, empty directives: [], with the legacy block's contents preserved verbatim under a transient migration_preserved: field. How they are preserved depends on whether the block parses: recognised shapes come back as structured lists, and a block the parser cannot read comes back as raw prose under raw_body. Either way nothing in the block is discarded. Canonical directive content is produced by /edikt:gov:compile afterwards (Phase B). After migration, /edikt:gov:compile reads sidecars only — there is no double-parser window.

/edikt:upgrade orchestrates both phases (structural strip + canonical extraction) automatically as a single user-visible step. Use the binary subcommand directly when scripting CI flows or when you want to rehearse the strip separately.

Usage ​

bash
edikt migrate sidecars --dry-run                 # plan; required first
edikt migrate sidecars --apply                   # execute the plan
edikt migrate sidecars --apply --force           # bypass the 24h dry-run gate
edikt migrate sidecars --dry-run --json          # machine-readable plan

--dry-run and --apply are mutually exclusive — exactly one is required.

Flags ​

FlagDescription
--dry-runPrint the plan; write a gate file at .edikt/state/migration-dry-run.json recording the plan timestamp and the artifacts inspected. Idempotent.
--applyExecute the plan. Refuses unless a successful --dry-run was recorded for this directory in the last 24 hours, or --force is set.
--forceBypass the 24h dry-run gate. Test/escape hatch — production flows should run --dry-run first.
--jsonEmit the dry-run plan or apply summary as JSON on stdout. The progress UI is suppressed when --json is set.

The 24-hour dry-run window ​

--apply checks the timestamp recorded by the most recent --dry-run for the current project. If the timestamp is older than 24 hours (or absent entirely), --apply refuses with:

text
migrate sidecars: --dry-run required first (or pass --force).
Run: edikt migrate sidecars --dry-run

The window exists because sidecar generation is destructive on the prose body — the in-body sentinel block is removed atomically with the sidecar write. You should not apply a plan you haven't reviewed today. --force exists for CI / test rehearsal flows that have already validated the plan upstream.

The gate file at .edikt/state/migration-dry-run.json is local machine state. .edikt/state/ is gitignored.

Two-phase migration ​

The migration is split at a clean architectural boundary. edikt migrate sidecars does structural cleanup only; /edikt:gov:compile produces canonical content. Both phases together complete the migration.

Phase A — edikt migrate sidecars --apply (pure Go, no LLM) ​

For every artifact with an in-body sentinel block (whatever shape — v0.5.x-full, v0.5.x-partial, v0.4.3-legacy, pre-v0.4, hand-edited, unknown), Phase A:

  1. Parses the in-body block.

  2. Writes a skeleton sidecar with topic: needs-extraction, empty directives: [], and the legacy block's contents preserved verbatim under a transient top-level migration_preserved: field.

    When the block parses into a recognised shape, its lists are carried across as lists:

    yaml
    migration_preserved:
      schema_detected: "v0.5x-full" | "v0.5x-partial" | "v0.4.3-legacy"
      directives: [...]                # verbatim from sentinel
      manual_directives: [...]
      suppressed_directives: [...]
      reminders: [...]
      verification: [...]
      topic: <legacy>                  # hint, optional
      signals: [...]                   # hint, optional

    When it does not — a hand-edited block, or any shape the parser does not recognise — there are no lists to carry. The block's text is preserved as-is under raw_body, and schema_detected reads unknown:

    yaml
    migration_preserved:
      schema_detected: unknown
      raw_body: |-
        - Services MUST validate all external input at the trust boundary. (ref: ADR-001)

    raw_body is prose, not structure. The extractor reads it as source text to extract rules from, the same way it reads the artifact's own body — it does not copy the lines across as directives. So the rules survive the migration and reach the sidecar through extraction rather than through a mechanical lift.

    The two are mutually exclusive: raw_body is written only when no list could be parsed, so a sidecar never carries both.

    If you migrated with edikt v0.7.0-rc1 … v0.7.3

    Those builds stripped an unparseable block without preserving it — the sidecar recorded schema_detected: unknown and nothing else, and the rule text was gone from disk while the command reported success. v0.6.x and earlier skipped such blocks instead of stripping them and are unaffected. See the upgrade note.

  3. Strips the in-body sentinel block from the parent .md (only after the sidecar writes successfully).

After the per-artifact loop, a post-migration verification step scans every migrated .md and fails the run with a per-file list if any column-0 [edikt:directives:start]: # survived. Documentation files that opt out via skip-list (<!-- edikt:migration:skip --> or frontmatter documents_legacy_format: true) are excluded by design. Recovery is via version control: migrate sidecars does not write a backup of your .md files.

Phase B — /edikt:gov:compile (dispatches sidecar-extractor) ​

The compile's Phase A treats every migration_preserved-bearing sidecar as stale (IsStale returns true unconditionally when the transient field is present) and dispatches the sidecar-extractor agent per artifact. The extractor follows seven preservation rules (see templates/agents/sidecar-extractor.md § "On migration-preserved baselines"):

  1. migration_preserved.directives → output directives[] verbatim (extractor may add new entries derived from prose; MUST NOT drop or rephrase preserved entries).
  2. migration_preserved.manual_directives → output manual_directives[] verbatim.
  3. migration_preserved.suppressed_directives → output suppressed_directives[] verbatim.
  4. migration_preserved.reminders → output reminders[] verbatim (may append from ## Confirmation / ## Enforcement prose).
  5. migration_preserved.verification → output verification[] verbatim (same append rule).
  6. migration_preserved.topic and .signals are HINTS; the extractor synthesizes canonical values from prose when the hints don't fit.
  7. The extractor MUST NOT include migration_preserved: in its output sidecar.

After the dispatch, a post-extractor lossless gate (--on-loss=auto, defaults to abort in CI / accept in TTY) calls lossless.CheckLosslessAgainstDirectives(MigrationPreserved.directives, sidecar) per artifact. Any preserved (modality, ref_id, normalized noun-phrase) tuple missing from the extractor's output is a loss; the gate surfaces a per-sidecar report and either aborts non-zero (CI safety) or warns and continues (interactive use). Once the check captures comparison data, compile strips migration_preserved: from every sidecar deterministically — steady-state sidecars never carry the transient field.

All sentinel shapes follow the same code path. Phase A's schema detection becomes audit metadata only (recorded in migration_preserved.schema_detected for diagnostics). No branching, no skip-list for "unknown" shapes — every sentinel becomes migration_preserved content for the extractor to interpret.

Resolving regressions ​

Strict mode records the losses described above as a machine-readable manifest:

bash
bin/edikt migrate sidecars --strict --report-json .edikt/state/v060-strict-report.json

/edikt:sidecar:regenerate consumes that manifest. It auto-fixes LOST items by re-running the extractor, and writes FACTUAL and DEGRADED items to a worklist for manual review.

bash
/edikt:sidecar:regenerate
/edikt:sidecar:regenerate --manifest <path>     # default: .edikt/state/v060-strict-report.json

How it works ​

  1. Verify binary presence. Requires the edikt tier-2 helper; refuses and directs you to edikt install edikt if absent.
  2. Resolve the manifest. Uses --manifest or defaults to .edikt/state/v060-strict-report.json. If the manifest is missing, the command runs bin/edikt migrate sidecars --strict to produce one — exit 0 means no regressions and the command stops.
  3. Parse and group items by category — LOST, FACTUAL, DEGRADED — and print a summary count across all affected artifacts.
  4. Auto-regenerate LOST items. For each unique parent artifact (deduplicated — multiple LOST items from one artifact yield a single call), dispatches the locked sidecar-extractor subagent, up to 4 concurrently. The extractor rewrites the sidecar in place.
  5. Verify LOST items are resolved. Re-runs bin/edikt migrate sidecars --strict. Exit 0 means LOST items cleared; non-zero prints a follow-up instruction.
  6. Write FACTUAL and DEGRADED items to a worklist at docs/internal/v060-manual-review.md as a deduplicated checkbox list — these are modality drift and verification abstraction that require human judgement, not auto-regeneration.
  7. Report exit status — INCOMPLETE while any worklist item is unchecked, or COMPLETE once all regressions are resolved.

What gets auto-fixed vs. flagged ​

CategoryMeaningHandling
LOSTA directive, paths:, or scope: was dropped during migrationAuto-fixed by re-running the extractor
FACTUALModality drift (e.g. MUST became SHOULD)Written to the manual-review worklist
DEGRADEDA verification was abstracted or weakenedWritten to the manual-review worklist

Notes ​

  • The binary's output is displayed verbatim and never parsed — only its exit code is inspected.
  • After regenerating, run /edikt:gov:compile so the refreshed sidecars land in compiled governance.

Skip list and fence detection ​

The migration skips files whose prose mentions the in-body sentinel format without using it:

  • Files that document the in-body schema itself (their prose contains example blocks)
  • SPEC-*.md (any spec file)
  • Any sentinel block inside a fenced code region (``` … ``` or ~~~ … ~~~) — fence detection runs through a markdown parser; only document-level blocks are lifted

You can also opt files out by adding frontmatter:

yaml
---
migration: skip
documents_legacy_format: true
---

…or by inserting a marker comment at the top of the body:

markdown
<!-- edikt:migration:skip reason="example block in prose" -->

Custom files outside the skip list that contain a bare-mention sentinel will be flagged in the dry-run plan — add the file to one of the opt-out mechanisms above before applying.

Rollback ​

Before commit:

bash
git checkout -- docs/architecture/decisions/ docs/architecture/invariants/ docs/guidelines/
git clean -f docs/architecture/decisions/*.edikt.yaml \
             docs/architecture/invariants/*.edikt.yaml \
             docs/guidelines/*.edikt.yaml

After commit: revert the migration commit, then edikt rollback (or edikt use <prior-version>). Your legacy in-body sentinels come back; sidecars are left in the working tree as untracked files (delete or keep).

Idempotency ​

Re-running --apply on an already-migrated project is a no-op. Half-completed migrations resume cleanly — if an LLM resync fails on artifact 8 of 14, fix the issue and re-run; the first 7 are no-ops, work resumes from 8.

edikt migrate to-v2 ​

Separate, now-required migration that rewrites each governance sidecar's directives[].source_excerpt and prohibitions[].source_excerpt (the v1 single-anchor shape) into the v2 source_excerpts[] list, and bumps schema_version to 2. Pure structural cleanup — no LLM, deterministic, idempotent.

Existing anchors are carried verbatim as the single element of the new list; this migration never invents a second anchor. Richer multi-anchor grounding only arrives when an artifact is next re-extracted by /edikt:gov:compile — not here.

It never writes a parent .md file, and edits only the two keys it owns (plus a stale yaml-language-server $schema comment pointing at the v1 schema, if present) — human-approved fields (verify:, human_approved_at, approved paths:) survive byte-identical.

Usage ​

bash
edikt migrate to-v2                 # convert every v1-shaped sidecar in one deterministic pass
edikt migrate to-v2 --dry-run       # report what would change, without writing

The command takes no positional arguments.

Flags ​

FlagDescription
--dry-runReport what would change without writing anything. For each discovered sidecar, checks whether conversion would change its bytes and tallies converted vs. already-v2.

What triggers it ​

/edikt:gov:compile's Phase A refuses to dispatch the sidecar-extractor while any sidecar in the corpus is still v1-shaped. The extractor writes gov-sidecar.v2 (source_excerpts[]); dispatching against a mixed corpus would convert artifacts one at a time by accident, leaving the corpus in two shapes at once with no record of which is which. If you run /edikt:gov:compile before this migration, it stops before the first dispatch with:

text
error: refusing to dispatch the extractor — N of M sidecar(s) still carry the v1 single-anchor shape:
  <artifact-id>
  ...
The extractor writes gov-sidecar.v2 (source_excerpts[]). Dispatching now would convert
the corpus one artifact at a time, by accident, leaving it in two shapes at once.
Run `bin/edikt migrate to-v2` first — it converts every sidecar in one deterministic pass.

Run edikt migrate to-v2 (optionally with --dry-run first) to convert the whole corpus in one pass, then re-run /edikt:gov:compile.

Output ​

Reports the denominator, not just the hits — "0 converted" out of 0 discovered is a broken scan, not a finished migration:

text
migrate to-v2: converted 12 of 60 sidecar(s); 48 already v2, 3 artifact(s) have no sidecar
  + ADR-001
  + ADR-014
  ...

Artifacts with no sidecar on disk (superseded or skip-marked artifacts legitimately have none) are counted separately rather than erroring or being silently dropped from the count. The command exits non-zero if zero sidecars are discovered under the project root at all.

Natural language triggers ​

For sidecars:

  • "migrate sidecars"
  • "upgrade my sidecars"
  • "convert legacy ADRs"
  • "run the sidecar migration"

For to-v2:

  • "migrate to v2"
  • "convert sidecars to v2"
  • "run the schema v2 migration"

Reference ​

Released under the Elastic License 2.0. Free to use, not for resale.