Journeys

A Journey is Jevitate's deterministic, replayable browser flow: a Recording of typed steps plus parameters and metadata. Discovery can be nondeterministic, but a Journey replays the same way every time, and a replay never clicks a guess.

The artifact

  • A name, parameters and a param-schema derived from its variables
  • Typed steps with assertions that decide whether a step really succeeded
  • Optional intent: a goal, preconditions, success criteria, and each step's objective and expected result
  • Optional anchors: named steps worth exploring from (metadata.anchors)
  • Provenance and promotion state
  • Secret references, bound to the origin they were recorded on, never the value

Journeys live in the repo's .jevitate/journeys/ (outside a repo, ~/.jevitate/journeys). They keep app-relative paths, so one Journey runs on every environment you name.

1. Author one from a goal

jevitate explore-author-journey --url http://localhost:3000 \
  --goal "add a product to the cart and reach checkout" --success urlIncludes:/checkout \
  --id checkout --name "Checkout" --real
jevitate journey promote checkout        # a human approval gate: unpromoted Journeys never run
jevitate journey run checkout

Jev drives toward the goal; the authored Journey carries the --success check as its final assertion, so a replay fails when the flow stops reaching its goal. It is written unpromoted: promotion is always a deliberate human act. --takes <n> corroborates the flow over several takes. Over MCP this is author_journey.

explore-author-journey takes every success check explore takes except reloadThen, and --success is repeatable. A network check (requestMade, responseStatus) is stored as metadata.networkChecks, and journey run and source run evaluate it over the replay's own requests.

Every take is the same goal run as explore --strategy goal, in a fresh browser, and takes its run-shaping flags: --secret, --secret-field (with --allow-secret-cmd and --secret-cmd-attempts), --totp, --type-fixture, --fixture, --success-when, --allow-vacuous-checks, --action-deltas, --dialogs, --deny, --paid, --allow-destructive, --read-rpc, the reply and job waits, --hang-replays, the settle options, --save-storage-state, --viewport / --device / --geolocation, --screenshots and --out.

When the goal is not reached, the result's reason is the discovery run's own (discovery mission <outcome>: <reason>). It carries a discovery block (stop reason, each check's verdict, decision and action counts, final URL, and the result, transcript, Recording and screenshot paths) and takes counts, and the human summary prints them.

A field typed from a --secret-field or --totp binding becomes a secret Journey param (secret1, ...). Supply it with journey run --param secret1=.... reloadThen is refused before a browser opens, with a message naming the inner check to author instead: a Journey has no reload step.

Outcome assertions in a Journey

0.7.0 authoring keeps the evidence that the outcome happened, not only that the clicks did:

  • End state (metadata.endState): every --success check, of any kind (textIncludes, valueEquals, count, attr, flashed, requestMade, responseStatus and now reloadThen), in the order given. journey run and source run judge it after the last step with the goal run's own evaluator: page checks on the final page, one reload for reloadThen, then network checks over the requests the replay sent. A replay whose steps all pass but whose outcome is absent fails and names each check that did not hold.
  • A step's request expectation (expectRequests): a write the step sent during discovery becomes responseStatus:<METHOD> <path>=2xx, judged over the requests that step sent, as the step ends (0.14.0). A later step's request to the same endpoint never counts for an earlier step.
  • A step's expect now comes from what the step changed: the navigation it caused, the value a fill typed, or, with --action-deltas, the text or element a changed page added. Authoring never writes visible of the step's own target: it holds before and after the click, so it proves nothing.
  • expectedResult: with --action-deltas, a step with a recorded delta gets its expectedResult filled by code from that delta, so the reviewer sees what each step is supposed to prove. An existing expectedResult is never overwritten.
{
  "metadata": {
    "id": "publish",
    "endState": [
      { "kind": "responseStatus", "method": "POST", "pathGlob": "/portal.v1.Sites/PublishSiteEdits", "status": { "class": 2 } },
      { "kind": "reloadThen", "assertion": { "kind": "textIncludes", "target": { "testId": "live" }, "text": "published" } }
    ]
  },
  "recording": { "pages": [{ "url": "/editor", "steps": [
    { "step": { "kind": "click", "target": { "testId": "publish" },
                "expect": { "kind": "visible", "target": { "text": "Your site is live", "textMatch": "contains" } } },
      "expectRequests": [
        { "kind": "responseStatus", "method": "POST", "pathGlob": "/portal.v1.Sites/PublishSiteEdits", "status": { "class": 2 } }
      ] }
  ] }] }
}

Both fields are optional: an older Journey (a trailing assert step, network checks in metadata.networkChecks) loads and runs unchanged. load run judges each iteration the same way, so an iteration whose end-state or expectRequests checks fail is quarantined, not ok.

A Journey can use a single-use link such as an invitation accept, a magic-link sign-in or a password reset. Declare the parameter, mark it secret if it is one, and put the placeholder in the URL:

"url": "/accept?token=${inviteToken}"
"parameters": [{ "name": "inviteToken", "secret": true }]

jevitate journey run accept-invite --param inviteToken=...

A placeholder must name a declared parameter and must come after a literal origin. Its value is percent-encoded as one URL component, the resolved URL must keep the template's origin, and a secret value is redacted from output, errors, screenshots and evidence. Steps show it as <param inviteToken>. Over MCP, pass it in run_journey's params.

2. Or record it by demonstration

jevitate record --url http://localhost:3000 --intent "check out with a saved card"

jevitate recording diff takeA.json takeB.json                        # which values vary between takes
jevitate recording propose takeA.json takeB.json --id reply          # an unpromoted binding draft (0.16.0)
jevitate recording postdoc take.json --decisions decisions.json --out recording.json
jevitate recording promote take.json --page 0 --step 2 --var email   # one value into a variable
jevitate recording fit take.json                                     # a timing policy from your pacing

record opens a headed browser and records what you do; Enter or Ctrl+C ends the take and saves it. A recorded take is a Recording, one step short of a Journey: there is no single command yet that turns a post-processed take into a promoted Journey. Authoring from a goal is the direct path today.

3. Promote, then run

jevitate journey list
jevitate journey run checkout --param sku=A1 --storage-state auth.json
jevitate journey run checkout --env staging --record-video --screenshots
jevitate journey run checkout --self-heal hybrid --changes origin/main...HEAD --real     # propose a repair the change explains (exit 5 until accepted)
  • Promotion runs journey lint first and refuses a Journey whose assertions cannot prove its outcome (below).
  • Only a promoted Journey runs, and only promoted ones are discoverable over MCP (find_capabilities, run_journey).
  • Replay is deterministic: the same descriptors, the same steps, no model in the loop.
  • A Journey that declares metadata.requiresAuth: true refuses to start without a session, before any browser opens.
  • --env / --base-url pick where it runs; --record-video, --screenshots and --headed show it (Demos & Environments).
  • journey run notes how many steps have no objective. Informational; it never fails a run.
  • --action-deltas (opt-in) compares each replayed step's page change with the Recording's, as evidence only; journey annotate --action-deltas drafts expected results from those deltas.
  • A Journey recorded with a browser extension (--extension) runs only with the same extension build loaded.

Review and promotion sign-off

jevitate journey review <id> (MCP review_journey) produces one sheet for the reviewer, as text, Markdown or JSON: the goal and success criteria, each step, the writes it is expected to fire, its inputs (parameters by name only, never a value), its proof (lint and the last verify --mutate verdict) and what changed since the last approval. The sheet shows a content hash. journey promote shows the sheet first and accepts --reviewed-hash / --review-sheet, refusing with E_JOURNEY_REVIEW_STALE if the Journey changed after the review. Promotion needs an interactive terminal and a typed confirmation, and records metadata.approval with its provenance. A Journey can link a job and a persona; see Catalog & Sign-off.

Since 0.10.0 the sheet also shows anchor-rule warnings with their fixes (anchors as job-step boundaries), the brittle steps with the fix for each (locator health), and the pull request on an approval that was verified from a review (pr-review). When the only change since the approval is minted step ids, the change section says ids added only (warn path): re-approving it only warns about the anchor rules instead of enforcing them. In --json these are anchorWarnings and locatorHealth.

The review sheet since 0.12.0

The sheet (journey review, and the one journey promote shows first) is built for a person signing off. It reads top to bottom as:

  • NEEDS YOUR ATTENTION: what to decide first: weak steps, missing intent, lint, risky controls, findings, change since approval, no mutation proof, brittle locators.
  • WHAT IT DOES: the recording's intent, sign-in, origins and uploaded files.
  • STEPS: each step with its checks in plain English, its writes and its anchors. A step recorded with a visible label shows the exact locator in ‹› beside it.
  • PROVES AT THE END, then WRITES (one line per endpoint), a one-line CLEAR summary and APPROVE with the promote command.

Empty sections are left out. Lines wrap to the terminal width, and colour appears only on a terminal (NO_COLOR turns it off). --full on journey review and journey promote brings back every detail section. --full is CLI only: over MCP, review_journey returns the JSON sheet. The sheet still carries one Content hash: line, so --review-sheet binding is unchanged. The typed journey promote confirmation repeats the attention items right above it, and a self-heal proposal shows each step's exact locator, so a retarget is visible even when the label is unchanged.

The JSON only gains fields (also over MCP): attention, per-step checks, file, anchors and locator, summary.intent and proof.endStateText.

An illustrative sheet, built from the layout above (the wording and the journey are made up):

NEEDS YOUR ATTENTION
  - No goal or success criteria written -> jevitate journey annotate import-contacts
  - 1 step has a check that proves nothing
WHAT IT DOES
  Imports a contacts file. Uploads fixtures/contacts.csv
STEPS
  1. click "Import"  <data-testid=import>
     checks: the URL contains "/contacts"
PROVES AT THE END
  ...
WRITES
  POST /api/contacts/import
CLEAR     lint
APPROVE   jevitate journey promote import-contacts --reviewed-hash <hash>

Step display labels (0.12.0)

Recordings (the recorder and Jev-authored ones) store each step target's visible label and role as display ({ role?, name }). It is display metadata only and never used to find an element. A credential field's label, a label that holds a secret, and the typed text of an editable field (a textarea, a contenteditable, a textbox) are never stored. Step descriptions read click "Import"; a step with only a test id reads click [action import] (no recorded label).

To backfill labels on Journeys recorded before 0.12.0:

jevitate journey migrate --labels [--id <id>...] [--dry-run]

It replays each Journey, so the app must be running; --dry-run writes nothing. A promoted Journey it changes needs re-approval (journey review --stale lists them); the migration never approves. migrate --labels is CLI only: there is no MCP tool for it.

Stable step ids (0.10.0)

Every recorded step carries a stable stepId (s- and six characters, for example s-7f3k2a; any 1 to 64 of [a-z0-9._:-]). It is assigned when a Journey is saved, never when it is read. The id is kept across edits, splices, heals and proposals, and a revision that renames an id is refused. Anchors, locator-health reports and review sheets refer to a step by its id as well as its position, and journey promote stamps each anchor with the id of the step it follows.

jevitate journey migrate --step-ids --dry-run     # list what would change; write nothing
jevitate journey migrate --step-ids               # mint ids on every step that has none
jevitate journey review --stale                   # list the Journeys whose approval is now stale
Breaking: promoted Journeys need re-approval after the backfill. jevitate journey migrate --step-ids [--dry-run] adds a stable id to every step of every local Journey and regression capture; a second run changes nothing. Adding ids changes each Journey's content hash, so its approval becomes stale, and so does any later save of an older Journey. jevitate journey review --stale (MCP review_journey with stale: true) lists the affected Journeys, labelled ids added only (warn path), and prints the command to re-approve each one. It never approves. Re-approving an ids-only change uses the warning path; the new anchor rules are not enforced on it. Do the migration in one reviewed pull request.

Locator health (0.10.0)

jevitate locator-health (MCP locator_health) reports how each recorded step finds its target and whether that survives a change to the app. It is read-only and advisory: it never changes a Journey and exits 0 on a report.

jevitate locator-health                                   # every promoted Journey
jevitate locator-health --journey checkout                # one Journey
jevitate locator-health --run <result.json>               # the rungs one run actually matched
jevitate locator-health --baseline last-week.json --json  # with the trend against an earlier --json output
jevitate check --suite ci.json --max-brittle-steps 0      # opt in to gating on it
  • Per step: the rung that resolved it (testId, anchor, role+name, role, label, text or css), how stable that rung is, why it is brittle, and a concrete fix for the app, such as add data-testid="save-contact" to the "Save" button on /contacts/new. A step is stable only when it resolves by a test id in the project's convention.
  • Fixes are de-duplicated across steps and runs, and --baseline reports the trend. The list is a work list for the codebase, not a change to the Journey.
  • In check the results appear as JUnit and SARIF warnings. check --max-brittle-steps <n> makes them a gate: a Journey item with more than n brittle steps is a gating finding (exit 1). See Regression & CI.
  • The convention. The attributes that count as a test id come from testIdAttributes in the repo's .jevitate/project.json, in preference order; the default is data-testid and data-test. There is no flag for it. Commit project.json with the app; it holds only project settings, never secrets.
  • data-tflow-id is tracking-only. It is recorded as tflowId and exported with findings and the bundle, but never used to locate an element. Listing it in testIdAttributes is refused (E_PROJECT_CONFIG), and a target found only by it is brittle.
{ "testIdAttributes": ["data-testid", "data-test", "data-cy"] }

Does it prove anything? Lint and mutation proof

A green replay only proves something when its assertions can fail for the wrong reason. journey lint reports the assertions that cannot, and journey promote runs the same lint first: a Journey with any error-level finding is refused unless the reviewer accepts it with --accept-weak "<reason>", recorded as metadata.acceptedWeak with the waived rules. Warnings never block promotion; approving a demo (demo approve) is the reviewer's approval, so it records the waiver instead of refusing. journey lint exits 1 when any error is found, so CI can gate on it.

jevitate journey lint checkout                  # one line per finding, then a summary
jevitate journey lint checkout --json           # { id, findings, errors, warnings }
jevitate journey lint checkout --sarif lint.sarif
jevitate journey promote checkout --accept-weak "reviewed: the write is covered by a separate Journey"
RuleLevelFlags
own-target-visibleerrorA step's expect only restates that its own target is visible: true before and after the step.
write-without-effecterrorA state-changing step has no assertion on its effect.
visibility-onlyerrorEvery assertion is a bare visible, so the Journey cannot fail for the wrong reason.
nothing-after-last-writeerrorNo effect assertion follows the last state-changing step.
no-persistence-checkwarningA write has no reloadThen or http end-state check.
http-check-off-allowlisterrorAn http end-state check reads an origin that is neither the Journey's site nor in its metadata.allowedOrigins (0.15.0).
http-check-without-resetwarningThe Journey has an http end-state check but records no fixtures, so nothing resets its source (0.15.0).
intent-uncoveredwarningA step's documented expectedResult maps to no effect assertion.

The lint reads a Journey; journey verify --mutate runs it. An assertion is evidence only if it fails when the outcome is absent, so the proof replays the Journey once as recorded (it must pass), then once per mutation, and checks that each mutation breaks the assertion paired with it:

jevitate journey verify checkout --mutate                 # one line per assertion, then a summary
jevitate journey verify checkout --mutate --json          # the full report
jevitate journey verify checkout --mutate --env staging --param sku=A1 --fixtures reset.json
  • skip:<n>: leaves step n's action out (each write step).
  • block-write:<n>: aborts the write requests step n sends (each write step).
  • stale-value:<n>: types an empty value into fill step n (each fill whose value an assertion checks).

Each assertion gets one verdict: sensitive (a paired mutation broke it), insensitive (it still passed: the assertion is vacuous), cascade (the replay failed somewhere else first; never counted as proof), not-applied, error or unpaired. When a mutated replay first fails at the mutated step's own postcondition (a save step that expects its redirect or status line), that step's assertion is sensitive, and the proof replays the mutation once more with that postcondition left out, so the step's expectRequests and the end-state checks are judged on that relaxed replay instead of as cascades (0.15.1; since 0.16.0 that relaxed replay keeps a weak visible postcondition on the step's own target, so it stays schema-valid and later checks no longer come out as errors). Exit 0: every paired assertion is sensitive. Exit 1: any is insensitive. Exit 2: nothing could be proven. Exit 64: usage (journey verify without --mutate, an unknown id, bad params). The --json report carries the Journey's content hash (journeyHash), each mutation's outcome, failedSites, blockedWrites and relaxed (the relaxed replay, when one ran), each assertion's verdict and provedBy, and summary counts. Pin a pairing the derivation would not make with metadata.mutationPairs; a pair naming a step or anchor the Journey does not have is refused when it loads:

"mutationPairs": [{ "check": "end-state:0", "mustFailWhen": "skip:publish" }]

Safe by construction: a mutation only leaves a step's action out, types an empty value, or aborts the app's own write requests. It never sends, fabricates or answers a request. Server-side state caveat: every replay starts fresh with the same fixtures and hooks as journey run, but the replays share the app's server-side state. When the app keeps state between sessions, reset it between replays with --fixtures or --before, or the replay after a blocked save can still find the base replay's saved data. Over MCP: lint_journey and verify_journey.

4. Give it intent

jevitate journey annotate checkout --env staging --real   # draft objectives on playback
jevitate journey annotate checkout --approve              # review the diff, then write it

journey annotate replays the Journey, reads the redacted page before and after each step, and drafts the missing goal, success criteria, objectives and expected results into .jevitate/journeys/.drafts/. Nothing is written into the Journey until you approve, and a draft made against a Journey that has since changed is refused. Intent is what journey demo narrates. Details: Demos & Environments.

5. Explore from inside it: anchors and campaigns

Real defects sit deep in a flow: a verification step mid-booking, a half-filled form. A journey-anchored mission gets there first. It replays a promoted Journey up to a step in the mission's own browser context (page, form contents and session kept), then hands the live page to the mission.

jevitate journey anchors checkout       # named states, the step each follows, suggested probes
jevitate explore --from-journey checkout --at-step payment --param email=a@example.test \
  --env staging --strategy adversarial --max-actions 60 --real

# every step (or --at-step anchors: every anchor), each in a fresh session restored by --fixtures
jevitate explore --from-journey checkout --at-step all --strategy adversarial \
  --fixtures restore.json --max-actions 120 --real
  • --at-step takes an anchor name or a 1-based step number; all / anchors sweep every step or anchor, with --max-actions split evenly per stop and one deduped report.
  • Strategies: goal, coverage, exploratory, adversarial, usability. --feature, --url, --repeat, --persona and --actor are refused with it (exit 64).
  • The prefix replays as journey run does: fail-closed, never self-healed, --env / --base-url and the site policy applied. Only the prefix's own params are required.
  • A prefix that no longer replays ends the run inconclusive with failure.kind: "journey-stale" (exit 2). It never restarts from a URL instead. Since 0.7.0 the failure carries the page's URL, title and a short redacted visible-text excerpt, so "the chat said it is unavailable" reads differently from "the click came too early". Failed steps are counted 1-based here and in journey run and check.
  • --fixtures, --before and --after work on a single --at-step <anchor|n> mission too, with any strategy (hooks need --allow-shell-hooks). It runs as a one-stop campaign, so the result has the sweep's shape (sweep.mode: "step", sweep.atStep).
  • Every result and finding records branch: {journeyId, step, anchor}, and report lists each defect's branch points. A reset inside the mission re-replays the prefix (counted against --max-actions).
  • verify-fix and regression capture|run replay a branch-point finding through its prefix; a secret param is given again with --param.

Declare anchors in the Journey's metadata (validated: unique names, real steps):

"anchors": [
  { "name": "payment", "step": 6, "description": "card form, order total shown",
    "probes": ["double submit", "swap the cart id"] }
]

Naming and authoring anchors (0.11.0)

An anchor name follows catalog bundle v1: 1-64 characters of lowercase a-z, 0-9, ., _ and -, starting with a letter or digit. : is no longer allowed. journey lint and journey promote report a name outside the rule, and existing Journeys still load. You no longer need to edit metadata.anchors by hand:

jevitate journey anchor add <journeyId> --name <n> --step <n|stepId> [--description <text>] [--probe <text>]...
jevitate journey anchor remove <journeyId> --name <n>
jevitate journey lint <id> --fix
jevitate journey anchors <id> --suggest
  • anchor add takes a 1-based step number or a step id and writes the step's stable id at once. Duplicates and unknown steps are refused (exit 64), and the remaining anchor lint issues are printed. anchor remove removes one by name.
  • On a promoted Journey (0.13.0) the edit works too, but the approval goes stale because it binds to the content hash. See below. (Before 0.13.0 a promoted Journey was refused.)
  • journey lint --fix stamps the step id of each anchor that references a step by number only, writes the Journey and reports what it fixed. It works on a promoted Journey since 0.13.0 (the approval goes stale).
  • journey anchors <id> --suggest proposes anchors where the recorded flow changes state (a new page, a form that became valid, a completed write), each with default probes and the anchor add command to accept it. Advisory: it writes nothing. Since 0.13.0 it also works on older recordings: writes are found from the recorded request timings and end-state checks, a new page goes on the click that navigated, and a job-linked Journey with nothing else to suggest gets its last step proposed as the job's end. journey anchors also lists each anchor's stepId.
  • Over MCP: journey_anchor_add (journeyId, name, step, description, probe), journey_anchor_remove (journeyId, name), lint_journey with fix, and journey_anchors with suggest.

Editing anchors on a promoted Journey (0.13.0)

journey anchor add, journey anchor remove and journey lint --fix work on a promoted Journey, from the CLI and over MCP. The Journey's content hash covers its anchors, so the edit makes its approval stale:

  • The Journey stays promoted, and its old approval is kept for the review diff.
  • journey review --stale lists it, and check --require-approvals fails it until a person re-approves.
  • The result carries needsReapproval: true and reapproveCommand (jevitate journey promote <id> --reviewed-hash <new review hash>). Over MCP, journey_anchor_add and journey_anchor_remove return both fields; only a person runs the command.

Anchor region (0.14.0)

journey anchor add --region <descriptor> (MCP journey_anchor_add.region) stores metadata.anchors[].region, the page region missions at that anchor attack, in the --success descriptor syntax (testId=import-view). A run from the anchor measures only the controls inside it and --scope-region cannot override it. Changing it makes a promoted Journey's approval stale, like any anchor edit. See scope regions.

Promotion-ready (0.14.0)

An agent hands a Journey to its owner only when the review sheet is clean:

jevitate journey review checkout --strict         # exit 1 on any attention item
jevitate journey review checkout --strict --json  # adds strict: { passed, warnings }
jevitate journey link checkout --job <jobId> --persona <personaId>
jevitate catalog draft job <id> --name "..."     # a DRAFT entry, never approved
  • journey review --strict (MCP review_journey with strict) exits 1 on any attention item and runs the job and persona link checks without --readiness. Advisory items and Jev never fail it. --stale --strict is refused.
  • journey link <id> --job|--persona (MCP journey_link) sets the catalog links; --unlink-job and --unlink-persona clear them. An unknown id is refused (E_JOURNEY_LINK_ARGS, exit 64, listing the catalog's ids). A draft entry can be linked and is flagged as still needing approval. On a promoted Journey the approval goes stale and the re-approve command is printed.
  • journey lint and explore-author-journey warn missing-job / missing-persona with the fix command. The exit code is unchanged.
  • journey verify --mutate records its proof over executable content only (steps, targets, assertions, end state). Rewording a goal, success criterion, objective or expected result (what journey annotate --approve writes) no longer stales the proof; an approval still goes stale.
  • The owner's order is persona approve, job approve, journey annotate --approve, then journey promote --reviewed-hash. See the jevitate-promotion-ready skill and the catalog.

Split-origin apps, http checks and recorded fixtures (0.15.0)

Allowed origins (#542). explore-author-journey records the origins it was given as metadata.allowedOrigins. journey run and journey verify take --allow <origin> (repeatable). A replay's allowlist is the recorded site (or the --env origins), plus metadata.allowedOrigins, plus --allow, so verify --mutate can block a write to another origin and a split-origin app's Journey reaches a conclusive proof. The recorded origins are not remapped under --env / --base-url: name the replacement origin with --allow or the environment's allow. MCP run_journey and verify_journey take no allow argument; they use the recorded origins.

jevitate journey verify checkout --mutate --allow http://127.0.0.1:26110   # block a write to another origin

http: end-state check (#543). A write whose effect is off the UI, such as a password-reset email in a dev outbox (Mailpit, MailHog), is proved by a polled GET to an allowed origin:

jevitate explore-author-journey --url http://localhost:3000/forgot --goal "send me a password reset link" \
  --allow http://localhost:3000 --allow http://localhost:8025 --fixtures reset-outbox.json \
  --success 'http:GET http://localhost:8025/api/v1/search?query=to:${setup.email}|jsonPath=$.messages[*].Subject|includes=Reset your password' \
  --id reset --name "Password reset"

See the grammar and safety rules. It satisfies no-persistence-check, and journey run and verify --mutate judge it. A transport error is unevaluable, never mutation proof.

Recorded fixtures (#544). explore-author-journey --fixtures <file> (with --before, --after, --fixture-identity) sets the fixtures up before, and restores them after, every take, and records metadata.fixtures ({ file, specHash }). journey run, journey verify --mutate and journey annotate then reuse it with no flag.

  • Precedence: --fixtures, then the Journey's recorded file, then the environment's. --fixture-identity always comes from flags or personas, and hooks are never recorded.
  • Reach: a fixture step may reach the replay's allowlist (the site, metadata.allowedOrigins, --allow), so a fixture can empty a dev outbox on another origin.
  • Missing file: refused with E_FIXTURE_SPEC before any browser opens.
  • Drift: a changed spec runs with a warning, and the JSON result carries fixtureDrift { file, recordedSpecHash, currentSpecHash, message }.
  • Author flags: explore-author-journey also takes explore's backend-log flags (--log-source, --log-defect and the rest) and --allow-writes. MCP author_journey takes fixtures and fixtureIdentity, maps allowWrites and omits the log flags, like run_exploration.

Parameter-relative assertions, restore outputs and recording propose (0.16.0)

Parameter-relative assertions (#562, #565). An assertion's expected text can follow a run's input. textIncludes.text, valueEquals.value and attr.value take { "var": "name" }, and any assertion string may carry ${name} (urlIncludes too, percent-encoded the way a navigate URL is):

{ "kind": "textIncludes", "target": { "testId": "welcome" }, "text": "Welcome, ${name}" }
{ "kind": "valueEquals", "target": { "label": "Email" }, "value": { "var": "email" } }
{ "kind": "urlIncludes", "text": "/threads/${thread}" }
  • Where a name comes from: a declared param, the output of an extract step earlier in the Journey (in the end state, any extract output) or a fixture setup.x output. Step expects and page end-state checks resolve it at run time, as http: checks already did.
  • Fails closed, at load and at run: an unbound name is E_ASSERTION_REF_UNBOUND, a secret param (or an extract output named like a credential, such as apiKey or token) is E_ASSERTION_REF_SECRET, a non-string value is E_ASSERTION_REF_TYPE. A persisted result keeps the template, never the resolved value.
  • Behaviour change: a literal ${...} in existing assertion text, urlIncludes included, is now a reference. Declare that name or reword the text.
  • An authored Journey gives a non-secret {var} fill an exact valueEquals postcondition, and lint and journey review count a check on a declared non-secret name as strong.
  • Proof: verify --mutate adds swap-param:<name> (another plausible value) and truncate-param:<name> (the first half kept) for each non-secret param that a fill or navigate URL reads and an assertion names, paired with every assertion that names it. A {var} fill's stale-value types an altered value. Secret params are never mutated, and reports name params only.

Fixture restore outputs (#560). A restore step may bind outputs, which later restore steps use as ${restore.<name>} (find the item a Journey created, then delete it). They are cleared before every restore pass, so each replay of verify --mutate, a regression or a check suite looks the resource up again. They never join the setup bindings, are never persisted and cannot choose a URL origin; a lookup that matches nothing sends no request. See per-run unique suite params.

{ "restore": [
  { "name": "find-item", "method": "GET", "url": "/api/items?title=Journey%20item",
    "expectStatus": [200], "outputs": { "itemId": "$.items[0].id" } },
  { "name": "delete-item", "method": "DELETE", "url": "/api/items/${restore.itemId}" }
] }

Named origins in environments (#561). An environment may declare "origins". Under --env, each named origin of the Journey's source environment (the one whose baseUrl is the recorded site, else one named default) moves to the target's same-named origin in end-state http: checks, recording URLs and fixture step URLs, and the target's named origins join the allowed origins. --base-url alone maps nothing.

{
  "local":   { "baseUrl": "http://127.0.0.1:3000", "origins": { "api": "http://127.0.0.1:6310" } },
  "preview": { "baseUrl": "http://127.0.0.1:3001", "origins": { "api": "http://127.0.0.1:26310" } }
}
jevitate journey run checkout --env preview   # recorded on :3000, so :6310 becomes :26310

Take files and recording propose (#564, #566). jevitate record writes a take file { recording, values } that recording diff, postdoc, promote and fit accept directly (promote and fit still accept a bare Recording). values holds the non-secret text and select values the take captured, stays local, and never has an entry for a secret, credential or one-time-code field. Secret-field detection is wider: a field is secret when its type or autocomplete says so (now including cc-number and cc-csc) or when its name, id, label, aria-label or placeholder names a credential (password, token, API key, secret, PIN, OTP, CVV, card number, SSN, security code; whole words, so pinned and spinner do not match). Such a field is a handback, gets no take value and contributes no value to an exploration snapshot. A password a "show password" toggle switches to type=text stays a handback for the rest of the take.

jevitate recording propose <take...> takes two or more takes of the same flow (varying what a user would vary) and proposes, with no model and no browser: params (values that vary, and varying navigate path segments), constants (a credential is never one), handbacks, outputs (an identifier shown before the first write that matches exactly one param, as an extract step), checks (a pre-write identity check and a post-write valueEquals on each filled param), stopBefore and ambiguities that it leaves for a person (identical-param-columns, untied-observation, multiply-tied-observation, identity-after-write, unclassified-value, varying-target, varying-url, no-submit-step). Evidence is redacted to length, shape and word count; typed text and credentials never appear.

  • The draft is unpromoted. By default it is saved as a Journey (--id, default proposed-draft) that ends before stopBefore; --out <file> writes it elsewhere and --no-draft proposes only. It never replaces an existing Journey (E_PROPOSAL_DRAFT_EXISTS), and nothing is approved or promoted.
  • Usage errors (exit 64): fewer than two takes (E_PROPOSAL_TOO_FEW_TAKES), an unreadable take (E_INVALID_TAKE, E_PROPOSAL_INVALID_TAKE), takes that did not record the same steps (E_PROPOSAL_MISALIGNED).
  • Next: decide every ambiguity, then journey review <id> --strict and journey verify <id> --mutate --param ...; only a person promotes.
  • MCP: the recordings tool's propose action takes confined take paths and returns the same redacted proposal. recordings diff returns each value as a length/shape hint and postdoc redacts constants, so typed text never reaches the model; the CLI shows values to the person running it.

Linked Journeys, readiness and targeted proof (0.17.0 to 0.20.0)

A workflow of two Journeys, where one finds records and the other prepares something for a person, is runnable and provable end to end. Nothing here approves, promotes or sends: every human step stays a person's.

  • Chain (0.17.0, --param in 0.19.0). jevitate journey chain --from <discover> --to <reply> --requests approved.jsonl (MCP chain_journeys) runs the discover Journey once and the reply Journey once per approved request, matched to a discovered record by commentId and account. Unmatched requests never run, an empty discovery stops the chain, and the result names ids and outcomes only. --param name=value (repeatable; MCP params) supplies the discover Journey's params, so one Journey serves any account or thread. They are validated against its param schema first: a missing or unknown param is E_CHAIN_PARAMS and no browser opens. Promoted Journeys only, unless --allow-draft (CLI).
  • Indexed forEach outputs (0.17.0). A forEach child extract also binds <as>.<i>.<field> and, after the loop, <as>.count, so a run returns every row.
  • Typed handoffs (0.18.0). A producer declares metadata.outputs (a contract name, a key and typed fields: id, text, path, url, timestamp); a consumer declares metadata.inputs (what it accepts, the request a caller supplies, and bindings to its params). journey chain checks contracts, records and requests before any reply runs: incompatible contracts are E_HANDOFF_INCOMPATIBLE, and an invalid, ambiguous or malformed one is listed under rejected and never run. Each run is stamped with the contract, a record fingerprint and the capture time. A well-typed but stale record is not live verification, so the reply Journey's own checks must still refuse it.
  • Prepare-only boundary and handback (0.18.0). metadata.boundary (kind: "prepare-only", stopBefore, nextStep) refuses at load a click on the stop target, an Enter press, any click after the last fill or select and a handback step; self-heal is refused (E_BOUNDARY_NO_HEAL); first-party writes are observed, or blocked with "blockWrites": true. journey run reports handback.state: prepared (exit 0), verification-failed, incomplete or submitted (exit 1), inconclusive (exit 2). A run that sent a write never exits 0, and code, not a model, decides the state.
  • Readiness receipt (0.18.0). jevitate journey readiness <id> [--with <toId>] [--mode report|replay|prove] [--env] [--requests] [--param] (MCP journey_readiness) gives one receipt for a Journey or a linked pair: lint, schema, handoff, boundary, strict review, annotations, catalog links and approvals, proof freshness, replay and promotion, each pass, fail, inconclusive, stale, not-run or awaiting-human. report opens no browser; replay and prove refuse a missing target, a production environment and missing or unknown params (E_READINESS_PARAMS) before any browser. A passing replay never hides an inconclusive proof. Exit 0 technically ready, 1 blocked, 2 inconclusive or incomplete. In 0.19.0 an unvetted (draft or stale) job or persona link is only awaiting a person; journey promote still refuses it without approval or --accept-unvetted.
  • Review bundle (0.18.0). jevitate journey bundle <id> [--with <toId>] --out review/ (MCP journey_bundle) writes a local bundle.md and manifest.json for sign-off, ending with the ordered human-only commands; journey bundle --check review/ fails closed with E_BUNDLE_STALE when a Journey or catalog entry changed. It holds no values, page text or secrets and cannot approve anything.
  • Where things are (0.18.0). Every Journey and catalog command takes --project <path> (a .jevitate/ directory or the directory containing it; sets the catalog and the default Journey store) and --journeys-dir <path> (the Journey store only). jevitate where [--project] [--journeys-dir] [--json] prints what resolves where and why, paths only. Refusals exit 64 with a next action; there is no silent fallback to ~/.jevitate for the catalog. --dir is deprecated but keeps its meaning and prints one notice on stderr. MCP tools never take these flags.
jevitate journey chain --from discover-conversations --to prepare-reply \
  --requests approved.jsonl --param account=mcpl --env staging --json
jevitate journey readiness discover-conversations --with prepare-reply --mode replay --env staging --requests approved.jsonl --param account=mcpl
jevitate journey bundle discover-conversations --with prepare-reply --out review/
jevitate where --project ../app

Proof for these workflows. verify --mutate uses a reviewed mutationValues list on a param when there is one (0.17.0; up to 20 values, refused on a secret param), makes swap-param on a select pick another enabled option, and adds guard-param:<name>, which gives the whole run a wrong input to prove a check that compares the page with an input before any action. Targeted proof (0.19.0). When a later assertion is a cascade only because an earlier intentional defense caught the same mutation first, verify relaxes those earlier step postconditions one at a time in a copy (at most 4; the target is never relaxed) and judges the target on its own. A targeted failure counts only after an unmutated control replay with the same relaxations passes; if relaxing breaks the target by itself, it stays a cascade and the proof inconclusive. To let an unpaired earlier check be relaxed, declare it a defense with metadata.mutationPairs. The report carries mutations[].targeted, relaxedSites, targetedRefusal and a targeted summary count, and the line reads (proved by swap-param:account, relaxed step:2).

Declared targeted proof (0.20.0). When the automatic bound of 4 can't reach a target behind layered pre-fill defenses, declare the plan in metadata.targetedProofs: [{ "check", "mustFailWhen", "relax" }], with relax listing at most 8 earlier step:<n> page postconditions. A declared plan runs exactly one targeted replay and one unmutated control replay, which must pass. Every relaxed site must be paired with the mutation and itself proven sensitive to it (transitively); otherwise the target stays a cascade with a named refusal (not in relax, not paired, not itself proven sensitive). A relax entry before the mutated step is refused at load. When the automatic bound stopped a proof, the report carries suggestedTargetedProofs, and journey readiness --mode prove prints that entry as the fix. The plan is part of the proof and review hashes, so declaring one needs re-approval; journey review lists it under "Proves at the end". The same soundness rule now applies to automatic targeted proofs, so a Journey proven in 0.19.0 can become inconclusive when a relaxed site's own proof did not hold. Production replay is unchanged.

Request checks by identity (0.20.0). A requestMade check counts only requests whose start its capture saw, so a late response from an earlier attempt (a heal probe or a retried step) no longer satisfies a restarted step, even within the same clock tick.

Campaigns

jevitate campaign run <spec.json> runs many anchored missions and reads them as one report. Each job's Journey is replayed once (discovery; a stale one skips its missions), then each anchored mission runs in spec order in a fresh browser context, with the spec's fixtures restoring state around every run. The defects are consolidated as report does, into campaign.json and campaign.md.

{
  "version": 1,
  "name": "release-candidate",
  "env": "staging",
  "fixtures": "restore.json",
  "maxRuns": 40,
  "maxActions": 60,
  "jobs": [
    { "id": "checkout", "journey": "checkout", "params": { "email": "a@example.test" },
      "anchors": ["payment", 4], "strategies": ["adversarial", "exploratory"] },
    { "id": "invite", "journey": "invite-teammate", "strategies": ["adversarial"], "maxActions": 40 }
  ]
}

Bounded: at most 50 jobs and maxRuns missions (default 50, at most 200). An invalid spec is refused with every problem listed (E_CAMPAIGN_SPEC, exit 64) before a browser opens. Over MCP: journey_anchors, run_exploration with fromJourney / atStep, and run_campaign. In a check suite, a mission item takes fromJourney, atStep, params, env and baseUrl. The jevitate-test-campaign skill plans a whole-release campaign around these.

Campaign preflight (0.11.0)

Before any mission, campaign run checks its own preconditions and, if any fail, refuses with exit 64 and E_CAMPAIGN_PREFLIGHT, listing every problem: a start URL that is unreachable, a job's storageState that is no longer signed in, and a discovery replay that wrote to an origin the environment's allow list misspells or leaves out (localhost vs 127.0.0.1), naming the exact origin to add. The verdict is preflight: { ok, problems } in campaign.json and the human summary. --skip-preflight (MCP run_campaign skipPreflight, recorded as preflight.skipped) runs without it. A journey run result lists offAllowlistWrites (origins only) when the replay wrote outside its allow list.

Slow steps and cold starts (0.11.0)

journey run waits for an in-flight request instead of quarantining on it. A responseStatus check whose request was sent but has no response yet (for example a cold start after a deploy) waits up to --step-response-timeout <ms> (default 15000, at most 300000; a step's own responseTimeoutMs overrides it). A 2xx that arrives in time passes and is reported as an advisory slowSteps finding with its measured latency; a non-2xx, or no response within the bound, still fails. jevitate check and campaign run also report a step that is slow on a Journey's first replay and fast on later ones as a cold-start note (slowness.coldStarts). It is advisory and never changes an exit code. Over MCP, run_journey takes stepResponseTimeoutMs (1 to 300000).

Since 0.14.0, each step's request window closes when the step ends. Before the next step runs, the step's checks wait, within the same bound, for the network to settle and for the step's own requests to be answered. Two Saves to the same endpoint on one page, or a Save followed by a navigation, each get their own response.

Upload fixtures (0.11.0)

A relative file path in a Journey's upload step resolves against the project root (the folder holding .jevitate/), not the working directory, so journey run works from any directory or CI working directory. journey lint reports a missing fixture as upload-file-missing (an error) with the resolved path.

Upload fixtures stay inside the project (0.12.0, security)

After following symlinks, a Journey's or mission's upload file must be inside the folder that holds .jevitate/. That folder is never the working directory, the git root, your home folder or /; with none, an upload is refused (fixture-no-root).

  • Refused outside the project: an absolute path outside it, a ../ escape, or a symlink pointing out (fixture-outside-project).
  • Refused inside the project: a dot-file or dot-folder (.env, .git, .jevitate), keys and certificates (*.pem, *.key, *.p12, *.pfx, id_rsa*), HAR files and traces, and storage-state files (fixture-sensitive).
  • Where: everywhere a Journey or mission runs. journey lint reports upload-outside-project and upload-sensitive-file, and journey promote refuses both; --accept-weak does not waive it.
  • No override. Move the fixture into the project.

Mission options. campaign run forwards these to every mission: --allow-destructive, --allow-writes, --deny, --paid, --invariants, the backend-log flags (--log-source, --log-defect, --log-scope, --log-ignore, --log-quiet-ok, --log-correlation-header, --log-id-pattern, --allow-log-cmd, --server-log-drain-ms), --evidence-video, --record-video, --screenshots and --log-triage. They are operator flags, never spec fields. --hook-timeout-ms now reaches a campaign's and an --at-step all|anchors sweep's hooks; it used to be dropped. MCP run_campaign takes allowDestructive, allowWrites, deny, paid, invariants, recordVideo, screenshots and evidenceVideo; log sources, hooks and triage stay operator-only.

Self-healing

In-flight self-healing is gated by SelfHealPolicy, chosen per run with --self-heal:

  • fail-closed (default): on divergence the run stops and quarantines rather than guessing.
  • hybrid / full: Jev re-learns only the broken step and splices it back, under policy. Both need a gateway (--real or --fake-ai); asking for a heal mode without one fails closed.

In every mode, write, irreversible and real-send steps never auto-heal, and assertions, success checks and invariants are never rewritten. journey annotate, journey demo and demo never self-heal.

Change-aware, budgeted heal (0.9.0)

  • Change input. --self-heal hybrid|full without --changes <git-range> or --change-note is refused with exit 64. The range is read-only git; only resolved commit SHAs reach git diff, and raw diffs never reach a model.
  • Only what the change explains. A broken step is healed only when the change explains it, and only by retargeting that step from the observed controls. A click or fill is healed only when its recorded requests are reads and it matches no risky control; mutating requests are blocked during the probe. A break the change does not explain is reported as a likely regression (exit 1).
  • Budgets. --heal-max-attempts, --heal-max-model-calls and --heal-max-ms per broken step; --heal-max-run-attempts and --heal-max-run-ms per run. A heal that runs out ends heal-exhausted (exit 1) with a full attempt log.
  • A heal is never a pass. It ends healed-pending-review (exit 5) and writes a proposed revision to .proposals/<journeyId>.json; the stored Journey is unchanged. Commit the proposal with the PR.
  • Review and accept. jevitate journey review <id> shows the proposal. jevitate journey promote <id> --proposal <pid> accepts it through every promote gate, and --reject-proposal <pid> --reason rejects it. Both are available on the CLI and over MCP, and acceptance is a human approval.
  • In CI. jevitate check --suite … --self-heal hybrid --changes origin/main...HEAD --fake-ai|--real re-runs a Journey that quarantined once with the change context. A pending revision makes the check exit 5 (JUnit failure type healed-pending-review, a SARIF warning). See heal outcomes.

The run context

A single immutable RunPolicy is threaded through every step. It is a required parameter, and the fallback is never permissive:

RunPolicy (required — no permissive default)
├── SelfHealPolicy   fail-closed | hybrid | full
├── DirectionPolicy  deterministic ↔ jev-directed ↔ goal-based
├── SecretPolicy     vault-autofill | visible-handback | fail-closed
├── PacingPolicy     human-speed profile
└── Budget           max-steps · per-run/day · concurrency

Share

git submodule add <gitUrl> .jevitate/journeys/commerce      # shared Journeys: commerce/<id>
jevitate source add acme https://github.com/acme/jevitate-journeys.git --accept-tou
jevitate source trust acme checkout          # trust one Journey, content-hash-bound
jevitate journey publish checkout --to acme  # share a promoted Journey

A git repository of Journeys mounted under .jevitate/journeys/<shared>/ (usually a submodule) shows up as <shared>/<id> everywhere a local Journey does, including MCP. Distributed sources share Journeys from third parties behind an explicit trust and Terms-of-Use gate; see MCP & CLI.

Load-test

jevitate load run checkout --env staging --authorized-origin https://staging.example.com \
  --concurrency 5 --iterations 10 --seed 1

load run replays a promoted Journey with a seeded pool of actors and refuses to start without --authorized-origin. It stays headless.

Site policies

jevitate site policy set https://app.example.com --file policy.json
jevitate site policy get https://app.example.com

A site policy keeps Journey runs on a site polite and bounded, whoever starts them (you, CI or an agent): human-like pacing, per-class throttles, hourly and daily budgets, and quiet hours. journey run, source run, check and MCP run_journey apply it; a refused run fails with E_SITE_THROTTLED and says when to retry. load run applies only the pacing.