DocsConcepts / The Action Graph

The Action Graph

Everything starts with a written list of what your site can actually do, in a form a machine can read. This page is that list’s specification: the types, the fixed vocabularies, what makes a mapped action count as complete, and why an action cannot be marked as approved without the approval itself sitting there as data.

Everything Frontlatch does starts with one thing: a written list of what your site can actually do, in a form a machine can read.

For each action it records four things. What it is, such as book a job or request a quote. Where it lives. What it needs from the customer, field by field, with the rules your site already enforces. And what happens when it runs.

That list is built by a real browser, not by reading the page source, because most of what a site can do only exists once the page has loaded and something has been clicked. Every format an agent can read is generated from the same list, so there is one source of truth and no second copy to drift.

73.3%

of the sites we scanned produced a complete action map. Eleven of fifteen.

Source: packages/mapper/benchmark/results.json · 26 Aug 2026

It is a contract, and it is versioned

The list is not a convenience format. It is the single interface every part of the system binds to, which is why it lives in its own package and carries a version number. One structure, four consumers: the mapper writes it, the compiler reads it to emit the formats agents understand, the verification step gates on it, and an executor acts from it.

The current version is 0.1, and a reader at 0.1 refuses a graph that says 0.2 rather than reading the parts it recognises. That is deliberate, and it is the opposite of the usual forgiving-reader posture for a wire format. A newer producer might express a limit, a barrier or a consequence in a field this reader cannot see, and a reader that ignores what it cannot see would fire an action while silently discarding the constraint on it.

What the package contains today
ItemValue
Schema version0.1
Committed fixtures3
Semantic field types29
Side-effect kinds12
Assertions in the suite91
Every figure here is printed by the package’s own test run, which ends: "All Action Graph assertions passed — 91 checks. Schema version 0.1; 3 fixtures, 29 semantic types, 12 effect kinds."

Source: packages/shared — npm test, 26 Aug 2026

The shape of one action

Here is a real one: the booking form on a Sydney dental practice, mapped on 25 August 2026 and committed as a fixture. Read it top to bottom and every design decision in the schema is visible.

An Action Graph, trimmed

{
  "schemaVersion": "0.1",
  "graphId": "ag_clinic01_20260825",
  "site": {
    "domain": "<sydney-dental-practice>.com.au",
    "url": "https://<sydney-dental-practice>.com.au/",
    "name": null
  },
  "generatedAt": "2026-08-25T08:39:12.596Z",
  "generator": { "name": "mapper", "version": "0.1.0" },
  "actions": [
    {
      "id": "clinic01:booking:appointment",
      "kind": "booking",
      "label": "Book a dental appointment",
      "url": "https://<sydney-dental-practice>.com.au/",
      "access": [
        {
          "method": "html-form",
          "url": "https://<sydney-dental-practice>.com.au/",
          "locator": { "strategy": "css", "value": "form#appointment-form", "stability": "stable" },
          "occurrences": 2,
          "submission": {
            "transport": "form-post",
            "endpoint": "https://<sydney-dental-practice>.com.au/wp-admin/admin-ajax.php",
            "enctype": "application/x-www-form-urlencoded",
            "submitControl": {
              "strategy": "css",
              "value": "form#appointment-form button[type=submit]",
              "stability": "stable"
            }
          },
          "opaque": false,
          "vendor": null,
          "provenance": {
            "method": "parsed-html",
            "confidence": 0.95,
            "at": "2026-08-25T08:39:12.596Z",
            "by": "mapper@0.1.0",
            "evidence": "form#appointment-form; identical form repeated in header modal and footer, folded to occurrences: 2"
          }
        }
      ],
      "inputs": [
        {
          "name": "patient-name",
          "label": "Your name",
          "semantic": "person-name",
          "type": "text",
          "required": true,
          "agentFillable": true,
          "valueSpace": [ { "kind": "length", "minLength": null, "maxLength": 80 } ],
          "defaultValue": null,
          "hint": "First and last name",
          "locator": {
            "strategy": "css",
            "value": "form#appointment-form input[name=patient-name]",
            "stability": "stable"
          },
          "provenance": {
            "method": "parsed-html",
            "confidence": 0.97,
            "at": "2026-08-25T08:39:12.596Z",
            "by": "mapper@0.1.0",
            "evidence": "label[for=patient-name]"
          }
        },
        {
          "name": "phone",
          "label": "Phone",
          "semantic": "phone",
          "type": "tel",
          "required": true,
          "agentFillable": true,
          "valueSpace": [ { "kind": "pattern", "pattern": "^0[2-9]\d{8}$" } ],
          "defaultValue": null,
          "hint": "Australian number, no spaces",
          "locator": {
            "strategy": "css",
            "value": "form#appointment-form input[name=phone]",
            "stability": "stable"
          },
          "provenance": {
            "method": "parsed-html",
            "confidence": 0.98,
            "at": "2026-08-25T08:39:12.596Z",
            "by": "mapper@0.1.0",
            "evidence": "input[type=tel][pattern]"
          }
        }
      ],
      "validations": [
        {
          "source": "declared",
          "appliesTo": "patient-name",
          "rule": { "kind": "required", "value": null },
          "attribute": "required",
          "raw": "",
          "message": null,
          "provenance": {
            "method": "parsed-html",
            "confidence": 0.99,
            "at": "2026-08-25T08:39:12.596Z",
            "by": "mapper@0.1.0",
            "evidence": "input[name=patient-name][required]"
          }
        }
      ],
      "sideEffects": {
        "effects": [
          {
            "kind": "sends-email-to-business",
            "target": "reception@<sydney-dental-practice>.com.au",
            "holdsInventory": false,
            "provenance": {
              "method": "inferred",
              "confidence": 0.75,
              "at": "2026-08-25T08:39:12.596Z",
              "by": "mapper@0.1.0",
              "evidence": "WordPress admin-ajax endpoint with a Contact Form 7 signature; recipient not visible in markup"
            }
          },
          {
            "kind": "creates-callback-request",
            "target": null,
            "holdsInventory": false,
            "provenance": {
              "method": "inferred",
              "confidence": 0.65,
              "at": "2026-08-25T08:39:12.596Z",
              "by": "mapper@0.1.0",
              "evidence": "form collects a preferred time window rather than a specific slot — reception phones back to confirm"
            }
          }
        ],
        "reversible": "unknown",
        "createsObligation": false,
        "transmitsPii": true,
        "chargesMoney": false,
        "summary": "Emails the clinic a request for an appointment in a chosen time window. Reception phones the patient back to confirm an exact time; no slot is held and no charge is made.",
        "provenance": {
          "method": "inferred",
          "confidence": 0.7,
          "at": "2026-08-25T08:39:12.596Z",
          "by": "mapper@0.1.0",
          "evidence": "NOT owner-confirmed — this is the mapper's reading of the form and must be confirmed before any live execution"
        }
      },
      "barriers": [
        {
          "kind": "captcha",
          "detail": "reCAPTCHA present on the page; no corresponding form input in the markup, so it is script-injected at submit time",
          "provenance": {
            "method": "parsed-html",
            "confidence": 0.9,
            "at": "2026-08-25T08:39:12.596Z",
            "by": "scanner@0.1.0",
            "evidence": "/recaptcha/ matched in page source"
          }
        }
      ],
      "provenance": {
        "method": "parsed-html",
        "confidence": 0.9,
        "at": "2026-08-25T08:39:12.596Z",
        "by": "mapper@0.1.0",
        "evidence": "form text and field set both indicate an appointment booking"
      },
      "verification": {
        "status": "unverified",
        "note": "Mapped only. Nothing here has been confirmed by the practice and nothing may execute."
      }
    }
  ],
  "notes": null
}
Copied from the clinic fixture in packages/shared/fixtures/. Structure, confidences, evidence strings and the whole sideEffects block are verbatim. Six values — the graph id, the action id, the domain, the page URL, the submission endpoint host and the effect target — carry the same placeholder host the published scan reports use, and site.name is null. The action is trimmed to one of its two access doors, two of its eight inputs and one of its validations. Nothing is added.

Two conventions hold everywhere

  1. No optional keys. Every field is T | null, never T?. Absence is spelled null. That is what makes a round trip lossless: there is no missing-versus-undefined ambiguity to lose between reading and writing. A reader is kind on the way in — a nullable key may be omitted — but canonical output always writes every key.
  2. Unions over flags. Wherever a state implies data, it is a discriminated union rather than a boolean sitting next to some hopefully-populated fields. A verified: true beside an optional confirmation would let a hand-edited file assert approval with no evidence behind it. A union makes that a compile error and a validation error at once.

What an action is, and how it is reached, are separate fields

An action’s kind is one of booking, quote, callback, contact or enquiry — what a customer is trying to get done. How it is reached is a separate list, access, and one action can have several doors: an HTML form, a tel: link, a third-party scheduler widget, a tool the page declares to the browser, a documented endpoint, or a link to a page carrying the real affordance.

Keeping them apart matters on real sites. A phone number and a booking form are the same customer intent reached two ways. And an embedded scheduler is a genuine booking whose fields sit inside a third-party frame nobody outside it can read — recorded as opaque: true with an empty inputs array, which is a different claim from "this action has no fields" and must never be confused with it.

Real sites also repeat themselves. The fixture above folds a form that appears in a header modal and again in the footer into a single action with occurrences: 2. Emitting it twice would inflate every count we report, including the mappability figure at the top of this page.

The vocabularies are closed

Every enumerated field draws from a fixed list, declared once and used to derive both the types and the runtime checks, so the two cannot drift apart. A value outside the list is rejected rather than passed through: a value we cannot reason about is one that could carry a consequence nobody modelled. Every vocabulary therefore has an explicit unknown member, so a producer meeting something new says so in the contract’s own words instead of inventing a token.

The 29 semantic types

A field has two types. type is what the browser thinks it is — text, email, tel, select. semantic is what the business thinks it is, and it is the half an agent actually reasons over: two plain text boxes may be a customer’s name and a property address, and an agent that cannot tell them apart cannot act.

Semantic types, grouped
GroupMembers
Whoperson-name · given-name · family-name · company-name
How to replyemail · phone · preferred-contact-method
Wherestreet-address · suburb · postcode · state · country · property-address
Whenpreferred-date · preferred-time · preferred-datetime
Whatservice · practitioner · location-branch · urgency · budget · quantity
Text and attachmentsreferral-source · message · file-upload · consent
Never an agent’s to fillanti-spam · form-metadata · unknown
The last three are load-bearing rather than filler. anti-spam is honeypots, CAPTCHA tokens and CSRF nonces, which an agent must never fill. form-metadata is hidden campaign and form identifiers, which an executor must send back exactly as it found them. unknown means we could not tell, and it blocks completeness rather than being guessed at.

Source: packages/shared/src/vocabulary.ts

That distinction is also a safety field. agentFillable: false marks a nonce, a honeypot or a hidden campaign id: the executor sends it back verbatim and an agent is never offered it. Getting it wrong in either direction fails — filling a honeypot marks the request as spam, dropping a nonce fails the submit.

The 12 effect kinds

What completing an action does in the world is an enumeration, not a sentence. A human sentence cannot be checked by a machine, and free-text consequences would leave the verification step with nothing to compare an owner’s confirmation against.

Side-effect kinds
KindWhat it means
sends-email-to-businessThe business receives an email.
sends-confirmation-to-customerThe customer receives a confirmation.
creates-crm-recordA record appears in the software the business already runs.
creates-calendar-appointmentA real slot comes out of the business’s day.
creates-callback-requestSomebody at the business now owes a phone call.
creates-quote-requestA priced job has been asked for.
creates-support-ticketA ticket is opened in a support queue.
initiates-phone-callA call is placed.
subscribes-to-marketingA ticked consent box is itself a consequence, not just a field value.
creates-accountAn account is created for the customer.
takes-paymentMoney moves.
unknownWe could not tell. Valid to record, and always blocks completeness.

Source: packages/shared/src/vocabulary.ts

Alongside the kinds, an action states four things about itself in plain booleans: whether it is reversible, whether the business now owes somebody something, whether a customer’s personal information is transmitted, and whether money moves. It also carries a one-sentence summary — the line a business owner reads on the confirmation screen. Individual effects carry holdsInventory, which separates an action that wastes a customer’s time when it misfires from one that costs the business a real slot in their day.

An action with an empty effects list is invalid. An action whose consequences we cannot state is an action nobody should execute.

Every node says how it came to be believed

Every part of the graph that asserts something about the world carries a provenance record: the method, a confidence between 0 and 1, when the belief was formed, what formed it, and the receipt — a selector, a structured-data path, a sandbox run id.

The methods are parsed-html, parsed-structured-data, parsed-webmcp, parsed-ard-catalog, observed-runtime, inferred, owner-confirmed and defaulted. Only owner-confirmed may ever reach a confidence of exactly 1. Nothing we derive ourselves is certain, and the tracker’s classifier holds the identical line for the identical reason.

This is why a validation is a union on its source rather than a flag. A declared validation cannot exist without naming the HTML attribute it was read from and quoting that attribute’s raw value; an inferred one cannot exist without a stated basis and a confidence. The consumer rule follows from the distinction: only a declared validation may be used to reject an agent’s input. An inferred one may warn or lower a score, never refuse a legitimate action, because it is our guess and not the site’s rule.

Nothing can be marked approved without the approval

The rule the whole system is built around is that no action ever runs against a business that has not approved that action type. In the schema that is not a policy paragraph, it is the shape of the data.

Verification is a three-state union, ordered by what it permits:

VerificationState

type VerificationState =
  | { status: "unverified";     note: string | null }
  | { status: "sandbox-tested"; sandboxRuns: SandboxRun[]; note: string | null }
  | { status: "verified";       confirmation: OwnerConfirmation; sandboxRuns: SandboxRun[]; note: string | null };
Verbatim from packages/shared/README.md, which quotes src/types.ts.
  • unverified — the default. Nothing executes.
  • sandbox-tested — it has been run in test mode. Still not live-executable. This is a separate state precisely so that "the sandbox passed" can never be mistaken for "the owner said yes".
  • verified — the owner confirmed, and the confirmation is present as data: who confirmed, when, by what method, a reference to the audit record, the limits they set, and the full disclosure they were shown.

There is no revoked state in 0.1. Revocation moves the action back to unverified, which fails closed by construction.

Four independent mechanisms hold the line and no one of them is sufficient. The type will not let a caller write status: "verified" without a full confirmation object. The validator rejects the same thing arriving as JSON, and treats a missing verification as an error rather than defaulting it — a producer that has never heard of verification must not look like one that considered it and said no. Every constructor in the package yields unverified and none has a parameter that produces anything else; there is deliberately no verify() helper, because that would be the first import anybody reaching for a shortcut would find. And the gate itself never trusts the status string.

The only way in

import { assertLiveExecutionAllowed } from "@frontlatch/shared";

function execute(action: unknown) {
  assertLiveExecutionAllowed(action);   // throws UnverifiedActionError
  …
}
Verbatim from packages/shared/README.md.

The gate re-derives permission from the confirmation data on every call: is there a confirmation, is it attributed to somebody, does it point at a record that can be retrieved, does it cap the volume, does it name somewhere to notify, and does it acknowledge the consequences this action has today. It takes an unknown rather than a typed action, so a cast cannot satisfy it, and every path that is not a positive, fully-evidenced yes returns no.

It also reads own properties only. An adversarial review broke an earlier version with prototype pollution: with Object.prototype polluted anywhere in the process — one vulnerable dependency, one careless deep merge — plain property access walks the prototype chain, and an empty object was granted live execution. An action that does not exist, firing against a business that has never heard of it. The defence already existed in the file that merely reports, and had been left out of the one that fires.

An owner does not consent to an action’s name. They consent to what it does. So the confirmation record mirrors the whole disclosure they were shown, and the gate compares every field of it against what the action says now. Any divergence stops execution until a human re-confirms.

An earlier draft recorded only the acknowledged effect kinds, and review broke it in one move: flip chargesMoney to true, or swap the summary sentence for a different promise, and the graph passed validation and fired — because no new effect kind had been added — while the owner had confirmed a screen reading "no slot is held, nothing is charged".

The fix that stuck was structural. The list of consent-bearing fields is declared once; the disclosure type is derived from that list rather than written in parallel; and the gate loops the list instead of naming fields, so a newly bound field is compared automatically with no code to remember to update.

One list, and a type derived from it

// consent.ts — the single source of truth
export const CONSENT_BOUND_SIDE_EFFECT_KEYS = [
  "effects", "reversible", "createsObligation", "transmitsPii", "chargesMoney", "summary",
] as const;
export const CONSENT_EXCLUDED_SIDE_EFFECT_KEYS = ["provenance"] as const;

// types.ts — the disclosure TYPE is derived, not declared in parallel
export type AcknowledgedDisclosure =
  & { [K in Exclude<ConsentBoundKey, "effects">]: SideEffects[K] }
  & { effects: EffectKind[]; holdsInventory: boolean };
Verbatim from packages/shared/README.md, which quotes src/consent.ts and src/types.ts.

Provenance is the single exclusion, and it is a decision on the record rather than an oversight: it is metadata about how we worked the description out, not a consequence of the action. Binding it would also expire consent every time a re-scan nudged a confidence score, which would train businesses to click through re-confirmations — the exact opposite of what the gate is for.

Adding a field to the side-effects interface and classifying it as neither bound nor excluded does not compile:

What the compiler says

src/consent.ts(110,7): error TS2322: Type 'boolean' is not assignable to type
  '{ ERROR: "A field was added to SideEffects without deciding whether an owner consents to it.";
     FIX: "Add it to CONSENT_BOUND_SIDE_EFFECT_KEYS or CONSENT_EXCLUDED_SIDE_EFFECT_KEYS in consent.ts.";
     UNCLASSIFIED: "notifiesCustomerBySms"; }'
Verbatim from packages/shared/README.md, "Why it is exhaustive by construction".

The summary sentence is compared byte for byte, with no trimming and no normalisation. A whitespace-only edit refusing execution is the intended trade: "close enough" is not a standard that belongs anywhere near consent. Consent-bearing strings must also be non-empty — an empty summary is not drift, both sides match, but it means the owner was shown nothing, and an action must never fire on a confirmation that disclosed nothing.

Complete is not the same as executable

Two different questions, kept deliberately apart. Complete is a property of our knowledge: do we have a full, faithful map of this action? Executable is a property of the owner’s consent: may it fire? A perfectly mapped action on a site whose owner has never heard of us is complete and not executable, and that is the normal state of every action we have ever mapped.

An action is complete when all seven of these hold:

  1. At least one door we can actually drive — not an opaque third-party frame, not a phone link alone.
  2. A usable way to send it: not JavaScript-only, not unknown, and a form post has a real submit control.
  3. At least one input an agent may fill.
  4. No required field whose meaning we could not determine. A required field we cannot name is one an agent either leaves blank, so the submission is rejected, or guesses at, so wrong data reaches a real business.
  5. A way to reach the customer back — an email or a phone field. A booking that reaches nobody is a lead the business cannot act on.
  6. Consequences stated, with none of them unknown.
  7. Provenance on the action and on every input.

Barriers — a CAPTCHA, a login — are reported separately as blockers and do not affect completeness. Otherwise a genuinely well-built booking form would count as unmappable because its content management system ships a CAPTCHA, and the mappability figure would be measuring something other than mapping.

The predicate returns reasons rather than a bare boolean, and that is the useful part. A false across fifteen sites collapses to a single percentage and nothing about what to build next; a tally of codes says eleven of them fail for the same single reason, which is a roadmap.

completeness() on the clinic fixture

{
  "complete": false,
  "missing": ["inputs.required_semantic_unknown:existing-patient"],
  "blockers": ["barrier.captcha"],
  "score": 1
}
Recomputed from the committed fixture by calling completeness() from packages/shared. One required field on that form is a tick-box the mapper could not name, so the map is not complete; the CAPTCHA is reported as a blocker and does not affect the verdict. The score beside them is crude partial credit for trend reporting only — it is not the mappability figure and nothing is decided on it.

graphCompleteness(graph) rolls that up per site. Its mappability is the share of that site’s actions that are complete — and a site with zero detected actions scores 0, not 1. The vacuous-truth reading would make the two sites in our sample with no action at all our two best results, and anything reporting an empty site as fully mapped is measuring the wrong thing.

Note that this is a different measure from the 73.3% at the top of the page. That figure counts SITES yielding at least one complete action — eleven of fifteen. At the level of individual actions, 20 of the 49 detected across the same fifteen sites were complete. Source for both: packages/mapper/benchmark/results.json.

Here is the tally across the fifteen-site run, which is what a roadmap built from this data actually looks like:

Why actions failed to be complete, across fifteen sites
CodeActionsWhat it means
access.phone_only14The only way in was a phone link.
inputs.none14Nothing to fill in.
inputs.semantic_unknown5A field whose meaning could not be determined.
access.opaque_only2A third-party widget nobody can see inside.
inputs.required_semantic_unknown2As above, and the field was required.
submission.no_submit_control1A form with no submit control inside it.
submission.no_usable_transport1Nothing but JavaScript sends it.
inputs.no_contact_channel1No email and no phone field — a lead that reaches nobody.
Counted over 49 detected actions across 15 sites; 20 actions were complete and 11 of the 15 sites were mappable.

Source: packages/mapper/benchmark/results.json · missingTaxonomy

Reading and writing it

Parsing, validating and writing back out is lossless by construction rather than by care. The validator rejects unknown keys, so nothing can arrive that would have to be dropped. It builds its canonical output key by key as it validates, so there is no second serialiser holding a duplicate field list that could drift. And writing re-validates first, so a graph assembled in memory in some other key order is normalised rather than written as-is.

Reading bad data from the outside world reports as an ordinary validation issue with a path and a code. Writing a graph we cannot validate throws, because that is our own bug, and a bug that would put an unvalidatable file on disk for the next stage to trust. The asymmetry is deliberate.

What may change, and what needs a decision first

The contract is versioned, so changing it is a governed act rather than a commit. Two lists, and the second is the interesting one.

Compatibility policy
ChangeStatus
Adding a new nullable fieldAllowed within 0.x
Adding a member to a vocabularyAllowed within 0.x — every consumer already handles the unknown member
Relaxing a validation rule, or improving an error messageAllowed within 0.x
Adding a helper function or a derived predicateAllowed within 0.x
Removing or renaming a fieldRecorded decision first
Changing a field’s type or cardinalityRecorded decision first
Changing the meaning of an existing vocabulary memberRecorded decision first — worse than removing it, because nothing breaks loudly
Anything in the verification union, the confirmation record, the limits, or the gateRecorded decision first — the trust rule does not move quietly
Changing what complete meansRecorded decision first
Changing canonical key order or the serialisation formatRecorded decision first
Adding a runtime dependencyRecorded decision first
Bumping to 0.2Recorded decision first — there is no version negotiation in 0.x

Source: packages/shared/README.md — Compatibility policy

The entry about completeness is the one that is not about breaking a consumer. The mappability percentage feeds a decision about whether the whole product continues, so quietly redefining complete would change the number without changing anything real — and could manufacture a false green light. That is the failure the rule exists to prevent.

Written from

  • The contractpackages/shared/src/types.ts
  • The closed vocabulariespackages/shared/src/vocabulary.ts
  • The consent bindingpackages/shared/src/consent.ts
  • The execution gatepackages/shared/src/trust.ts
  • The completeness predicatepackages/shared/src/complete.ts
  • The package explainerpackages/shared/README.md
  • Committed fixturespackages/shared/fixtures/Cited as a directory: the filenames name real scanned businesses.