diff --git a/apps/logicsrc-web/contract/openerrand.contract.test.ts b/apps/logicsrc-web/contract/openerrand.contract.test.ts new file mode 100644 index 0000000..ea1aee1 --- /dev/null +++ b/apps/logicsrc-web/contract/openerrand.contract.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { readDoc } from "../src/lib/docs"; +import { SENSITIVITY, SOURCES, STEPS } from "../src/app/openerrand/data"; + +// The landing page restates the spec's tables, and the site serves the worked +// example and the publisher index. These tests keep all of them in step with +// docs/openerrand.md and the schema fixtures. + +const doc = readDoc("openerrand") ?? ""; +const root = resolve(__dirname, "../../.."); +const json = (path: string) => JSON.parse(readFileSync(resolve(root, path), "utf8")); + +/** The first-column `code` cells of the table under a heading. */ +function tableKeys(heading: string): string[] { + const start = doc.indexOf(`\n${heading}\n`); + expect(start, `docs/openerrand.md has a ${heading} section`).toBeGreaterThan(-1); + const next = doc.slice(start + 1).search(/\n#{2,3} /); + const section = doc.slice(start, next === -1 ? undefined : start + 1 + next); + return section + .split("\n") + .map((line) => line.match(/^\| `([^`]+)` \|/)) + .filter((m): m is RegExpMatchArray => m !== null) + .map((m) => m[1]); +} + +describe("OpenErrand: the spec, its landing page and its published files agree", () => { + it("lists the same step kinds, sensitivity classes and sources, in the same order", () => { + expect(STEPS.map(([kind]) => kind)).toEqual(tableKeys("## Steps")); + expect(SENSITIVITY.map(([name]) => name)).toEqual(tableKeys("### Sensitivity")); + expect(SOURCES.map(([name]) => name)).toEqual(tableKeys("### Sources")); + }); + + it("gives every gate kind its own section", () => { + for (const gate of ["declare", "identity-proofing", "code", "mail", "captcha"]) { + expect(doc).toContain(`\n### \`${gate}\`\n`); + } + }); + + it("prints the fixture as the worked example, and the site serves the same bytes", () => { + const fixture = json("packages/schemas/fixtures/openerrand/ftb-register-business.json"); + const section = doc.slice(doc.indexOf("\n## Worked example\n")); + const example = section.match(/```json\n([\s\S]*?)\n```/); + expect(example, "the worked example's JSON").not.toBeNull(); + expect(JSON.parse(example![1])).toEqual(fixture); + expect(json("apps/logicsrc-web/public/examples/openerrand/ftb-register-business.json")).toEqual(fixture); + expect(fixture.id).toBe("https://logicsrc.com/examples/openerrand/ftb-register-business.json"); + }); + + it("serves the publisher index that lists the worked example", () => { + const index = json("apps/logicsrc-web/public/.well-known/openerrand.json"); + expect(index).toEqual(json("packages/schemas/fixtures/openerrand/index.json")); + expect(index.errands.map((e: { url: string }) => e.url)).toContain("https://logicsrc.com/examples/openerrand/ftb-register-business.json"); + }); + + it("numbers its runner rules without gaps", () => { + const numbers = [...doc.matchAll(/^### (\d+)\. /gm)].map((m) => Number(m[1])); + expect(numbers.length).toBe(13); + expect(numbers).toEqual(numbers.map((_, i) => i + 1)); + }); + + it("has no em dashes", () => { + expect(doc).not.toContain(String.fromCharCode(0x2014)); + }); +}); diff --git a/apps/logicsrc-web/contract/spec-discovery.contract.test.ts b/apps/logicsrc-web/contract/spec-discovery.contract.test.ts index 22fd9e5..55cf6cb 100644 --- a/apps/logicsrc-web/contract/spec-discovery.contract.test.ts +++ b/apps/logicsrc-web/contract/spec-discovery.contract.test.ts @@ -19,7 +19,8 @@ describe.each([ { slug: "openobject", name: "OpenObject", family: "catalogs" }, { slug: "openslice", name: "OpenSlice", family: "catalogs" }, { slug: "openstack", name: "OpenStack.md", family: "catalogs" }, - { slug: "openinstall", name: "OpenInstall", family: "process" } + { slug: "openinstall", name: "OpenInstall", family: "process" }, + { slug: "openerrand", name: "OpenErrand", family: "process" } ])("$name public discovery", ({ slug, name, family }) => { it("serves the specification through its family and docs index", () => { expect(familyOfSpec(slug)?.slug).toBe(family); diff --git a/apps/logicsrc-web/public/.well-known/openerrand.json b/apps/logicsrc-web/public/.well-known/openerrand.json new file mode 100644 index 0000000..5ef7a88 --- /dev/null +++ b/apps/logicsrc-web/public/.well-known/openerrand.json @@ -0,0 +1,16 @@ +{ + "type": "logicsrc.openerrand-index", + "version": "0.1", + "publisher": "https://logicsrc.com/.well-known/openprofile.md", + "updated": "2026-10-04T00:00:00Z", + "errands": [ + { + "url": "https://logicsrc.com/examples/openerrand/ftb-register-business.json", + "name": "ftb-register-business", + "site": "https://webapp.ftb.ca.gov", + "title": "Register a MyFTB business account", + "gates": ["declare", "code", "mail"], + "updated": "2026-10-04T00:00:00Z" + } + ] +} diff --git a/apps/logicsrc-web/public/examples/openerrand/ftb-register-business.json b/apps/logicsrc-web/public/examples/openerrand/ftb-register-business.json new file mode 100644 index 0000000..4f1d29c --- /dev/null +++ b/apps/logicsrc-web/public/examples/openerrand/ftb-register-business.json @@ -0,0 +1,236 @@ +{ + "type": "logicsrc.openerrand", + "version": "0.1", + "id": "https://logicsrc.com/examples/openerrand/ftb-register-business.json", + "name": "ftb-register-business", + "title": "Register a MyFTB business account", + "description": "Creates a MyFTB account for a California S corporation at the Franchise Tax Board, proving the business with a figure from a filed Form 100S. FTB then mails a PIN; activation is a second errand.", + "publisher": "https://logicsrc.com/.well-known/openprofile.md", + "updated": "2026-10-04T00:00:00Z", + "reference": "https://github.com/profullstack/cli-tools/pull/125", + "principal": "self", + "site": { + "name": "California Franchise Tax Board (MyFTB)", + "sector": "tax", + "origins": ["https://webapp.ftb.ca.gov"], + "start": ["https://webapp.ftb.ca.gov/MyFTBAccess/Registration/NewAccount"] + }, + "limits": { "pages": 15, "page_timeout": "PT30S", "same_page": 2 }, + "inputs": { + "email": { + "label": "Email address FTB writes to", + "type": "email", + "sensitivity": "personal", + "required": true, + "sources": [{ "from": "prompt" }] + }, + "phone": { + "label": "Mobile number FTB texts a verification code to", + "type": "string", + "pattern": "^[0-9]{10}$", + "sensitivity": "personal", + "required": true, + "sources": [{ "from": "prompt" }] + }, + "first_name": { + "label": "Representative's first name", + "type": "string", + "max_length": 11, + "sensitivity": "personal", + "sources": [{ "from": "document", "form": "CA 540", "field": "first name", "pick": "newest" }] + }, + "last_name": { + "label": "Representative's last name", + "type": "string", + "max_length": 13, + "sensitivity": "personal", + "sources": [{ "from": "document", "form": "CA 540", "field": "last name", "pick": "newest" }] + }, + "street": { + "label": "Street address on the newest return", + "type": "string", + "sensitivity": "personal", + "sources": [{ "from": "document", "form": "CA 540", "field": "street address", "pick": "newest" }] + }, + "address_numbers": { + "label": "The numbers in the address on file", + "type": "string", + "sensitivity": "personal", + "sources": [{ "from": "derive", "input": "street", "transform": "digits" }] + }, + "zip": { + "label": "ZIP code on file", + "type": "string", + "sensitivity": "personal", + "sources": [{ "from": "document", "form": "CA 540", "field": "ZIP code", "pick": "newest", "transform": "first:5" }] + }, + "corp_id": { + "label": "California corporation number", + "type": "string", + "sensitivity": "public", + "sources": [{ "from": "document", "form": "CA 100S", "field": "California corporation number", "pick": "newest" }] + }, + "tax_year": { + "label": "Tax year of the return the shared secret comes from", + "type": "integer", + "sensitivity": "public", + "sources": [{ "from": "candidate", "input": "net_income", "part": "year" }] + }, + "net_income": { + "label": "Net income for tax purposes, whole dollars", + "type": "integer", + "sensitivity": "secret", + "role": "shared-secret", + "sources": [ + { "from": "document", "form": "CA 100S", "field": "line 20", "match": "net income for tax purposes", "years": { "back": 5, "current": false }, "transform": "whole" }, + { "from": "document", "form": "CA 100S", "field": "line 15", "match": "net income \\(loss\\) for state purposes", "years": { "back": 5, "current": false }, "transform": "whole" } + ] + }, + "username": { + "label": "MyFTB user name", + "type": "string", + "sensitivity": "personal", + "role": "credential", + "sources": [ + { "from": "vault", "key": "FTB_BUSINESS_USERNAME" }, + { "from": "generate", "length": 15, "classes": ["lower", "digit"] } + ] + }, + "password": { + "label": "MyFTB password", + "type": "string", + "sensitivity": "secret", + "role": "credential", + "sources": [ + { "from": "vault", "key": "FTB_BUSINESS_PASSWORD" }, + { "from": "generate", "length": 24, "classes": ["lower", "upper", "digit", "special"], "special": "!#$*@" } + ] + }, + "security": { + "label": "Three security questions and their answers", + "type": "qa-set", + "count": 3, + "sensitivity": "secret", + "role": "credential", + "sources": [ + { "from": "vault", "key": "FTB_BUSINESS_SECURITY_ANSWERS" }, + { "from": "generate", "length": 10, "classes": ["lower", "digit"] } + ] + } + }, + "rules": [ + { "name": "read terms", "id": "^ReadTerms$", "types": ["checkbox"], "do": { "check": true } }, + { "name": "accept terms", "id": "^AcceptTerms$", "types": ["checkbox"], "do": { "check": true } }, + { "name": "first name", "id": "^FstName$", "do": { "text": "{{first_name}}" } }, + { "name": "middle initial", "id": "^MInitial$", "do": { "skip": true } }, + { "name": "last name", "id": "^LstName$", "do": { "text": "{{last_name}}" } }, + { "name": "suffix", "id": "^Sffx$", "do": { "skip": true } }, + { "name": "user name again", "id": "^ReUserName$", "do": { "text": "{{username}}" } }, + { "name": "user name", "id": "^UserName$", "do": { "text": "{{username}}" } }, + { "name": "email again", "id": "^ReEmail$", "do": { "text": "{{email}}" } }, + { "name": "email", "id": "^Email$", "do": { "text": "{{email}}" } }, + { "name": "password again", "id": "^RePassword$", "do": { "text": "{{password}}" } }, + { "name": "password", "id": "^Password$", "do": { "text": "{{password}}" } }, + { "name": "foreign number", "id": "^Phone_Foreign$", "do": { "skip": true } }, + { "name": "foreign address", "id": "^Address_Foreign$|^Address_No(MailAddress|PostalCode)$", "do": { "skip": true } }, + { "name": "security question", "label": "question", "types": ["select-one"], "do": { "choose": "security" } }, + { "name": "security answer", "label": "question|answer", "types": ["text", "password"], "do": { "answer": "security" } }, + { "name": "role", "label": "individual|business representative", "types": ["radio"], "do": { "check": { "label": "^\\s*business representative" } } }, + { "name": "zip", "label": "zip|postal", "types": ["text", "tel", "number"], "do": { "text": "{{zip}}" } }, + { "name": "address numbers", "label": "numbers in (the |your )?(business )?(mailing )?address", "types": ["text", "tel", "number"], "do": { "text": "{{address_numbers}}" } }, + { "name": "tax year", "label": "year (of|on) the tax return|tax year", "types": ["select-one"], "do": { "select": ["^\\s*{{tax_year}}\\s*$"] } }, + { "name": "tax year", "label": "year (of|on) the tax return|tax year", "types": ["text", "tel", "number"], "do": { "text": "{{tax_year}}" } }, + { "name": "net income", "label": "net income|income \\(loss\\)", "types": ["text", "tel", "number"], "do": { "text": "{{net_income}}" } }, + { "name": "company type", "label": "type of company|company type|entity type", "types": ["select-one"], "do": { "select": ["^\\s*corporation\\s*$", "corporation"] } }, + { "name": "account number", "label": "account number|entity id|corporation (id|number)", "types": ["text", "tel", "number"], "do": { "text": "{{corp_id}}" } }, + { "name": "form type", "label": "form type|type of (tax )?(return|form)", "types": ["select-one"], "do": { "select": ["100\\s*S\\b"] } }, + { "name": "declaration", "label": "perjury|i declare|under penalty", "types": ["checkbox"], "do": { "gate": "declaration" } }, + { "name": "phone", "label": "phone number", "types": ["text", "tel", "number"], "do": { "text": "{{phone}}" } }, + { "name": "send a text", "label": "send me a text|text message", "types": ["radio"], "do": { "check": true } }, + { "name": "verification code", "label": "verification code|security code|one[- ]time|passcode|access code|enter (the )?code", "types": ["text", "tel", "number", "password"], "do": { "gate": "text-code" } } + ], + "steps": [ + { + "id": "bot-check", + "kind": "wait", + "match": { "title": "^Challenge Validation$", "selector": "#sec-cpt-if" }, + "timeout": "PT90S", + "poll": "PT3S", + "say": "FTB's bot check is a proof of work the page's own script solves; the runner waits for it." + }, + { + "id": "form", + "kind": "page", + "unmatched": "stop", + "say": "Every MyFTB registration page: terms, profile, security questions, role, address, shared secret, phone." + }, + { + "id": "declaration", + "kind": "declare", + "statement": "perjury|i declare|under penalty", + "why": "Ticking this box is the representative stating, under penalty of perjury, that what was entered is true. Only that person can make the statement." + }, + { + "id": "text-code", + "kind": "code", + "channel": "sms", + "relay": ["terminal", "file"], + "pattern": "^\\w{4,10}$", + "timeout": "PT15M", + "why": "FTB texts a code to the phone number given. Whoever holds the phone reads it out." + }, + { + "id": "pin-letter", + "kind": "mail", + "what": "MyFTB PIN letter", + "arrives": "5 to 10 business days, to the address FTB has on file", + "expires": "P21D", + "resume": "ftb-activate-business", + "input": "pin", + "handoff": "pin-letter", + "why": "FTB activates a new account with a PIN it sends by US Mail. Nobody but the addressee can read it." + } + ], + "submit": { + "labels": "^(submit|continue|next|log ?in|login|activate|send( code| me a code)?|verify|confirm)$", + "never": "^(back|cancel|end session|previous)$", + "ignore": "#timer, .modal" + }, + "outcomes": [ + { + "name": "rejected", + "kind": "rejected", + "text": "does not match our records|there is a problem|unable to (verify|process) your|account (is|has been) locked" + }, + { + "name": "registered", + "kind": "success", + "text": "registration confirmation|successfully (registered|created)|we will (mail|send) you a (letter|pin)|pin .*(mail|letter)", + "then": "pin-letter" + } + ], + "retry": { "shared_secret": "never", "page_errors": "rejected" }, + "outputs": { + "vault": { + "when": "registered", + "keys": { + "FTB_BUSINESS_USERNAME": "{{username}}", + "FTB_BUSINESS_PASSWORD": "{{password}}", + "FTB_BUSINESS_EMAIL": "{{email}}", + "FTB_BUSINESS_SECURITY_ANSWERS": "{{security}}" + } + } + }, + "handoffs": { + "pin-letter": { + "title": "FTB PIN letter: business MyFTB account", + "open": "https://webapp.ftb.ca.gov/MyFTBAccess/", + "steps": [ + "Watch the mail at the address FTB has on file for the MyFTB PIN letter (5 to 10 business days).", + "Activate before {{expires_on}}: the PIN expires 21 days after registration.", + "Run the command below yourself, with the PIN from the letter." + ], + "command": "ftb activate business --pin " + } + } +} diff --git a/apps/logicsrc-web/src/app/openerrand/data.ts b/apps/logicsrc-web/src/app/openerrand/data.ts new file mode 100644 index 0000000..cc1b730 --- /dev/null +++ b/apps/logicsrc-web/src/app/openerrand/data.ts @@ -0,0 +1,31 @@ +// The facts the /openerrand landing page shows, kept apart from page.tsx so +// contract/openerrand.contract.test.ts can hold them against docs/openerrand.md. + +/** The step kinds, in the order the spec's Steps table lists them. */ +export const STEPS: Array<[string, string]> = [ + ["page", "A form page: fill it from the rules, then press its forward button."], + ["wait", "An interstitial the page clears by itself, such as a proof-of-work bot check. Waited out, never solved or bypassed."], + ["declare", "A legal attestation. Ticked only on the principal's consent for this run, given after seeing the values."], + ["identity-proofing", "A selfie, a video call or an ID scan at a provider such as ID.me. Never driven by the runner: the person does it in a visible window."], + ["code", "A one-time code by text, email, call or app, relayed by the person through a terminal, a file or the runner's own page."], + ["mail", "A letter with a PIN. The run ends as waiting and a hand-off card says what to do when it comes."], + ["captcha", "Shown to the principal, or the run stops. A solver only where the errand says so, never on a government, tax, financial, healthcare or identity-provider site, never with a declaration, identity proofing or a secret input."] +]; + +/** The sensitivity classes, in the order the spec lists them. */ +export const SENSITIVITY: Array<[string, string]> = [ + ["public", "Logs, the terminal, hand-off cards, the run record."], + ["personal", "The principal's terminal, the site's own fields, the vault. Never a log, never a card."], + ["secret", "The site's own field and the vault, and nowhere else. Masked as •••• everywhere."] +]; + +/** Where an input may come from, in the order the spec lists them. */ +export const SOURCES: Array<[string, string]> = [ + ["document", "Extracted on the principal's machine from their own files, with the file and page recorded. The document never leaves the machine."], + ["vault", "A key in the principal's vault, read before anything is generated, so a second run reuses the first run's login."], + ["prompt", "Asked of a person at run time. A secret prompt does not echo."], + ["generate", "A fresh random value, written to the vault on success."], + ["derive", "Another input, transformed: the digits of a street address."], + ["candidate", "A part of the chosen shared-secret candidate, such as the tax year the figure came from."], + ["literal", "A fixed value."] +]; diff --git a/apps/logicsrc-web/src/app/openerrand/page.tsx b/apps/logicsrc-web/src/app/openerrand/page.tsx new file mode 100644 index 0000000..7a51782 --- /dev/null +++ b/apps/logicsrc-web/src/app/openerrand/page.tsx @@ -0,0 +1,198 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; +import type { Metadata } from "next"; +import { specMetadata } from "@/lib/page-meta"; +import { SiteShell } from "@/components/site-shell"; +import { mono, pre, table, td, th } from "../openontology/ui"; +import { SENSITIVITY, SOURCES, STEPS } from "./data"; + +export const metadata: Metadata = specMetadata( + "/openerrand", + "OpenErrand is one JSON file that describes an errand on a website with no API: the inputs and where each comes from, the rules that fill each field, the human gates a runner must never automate (declarations, identity proofing, codes, letters, captchas), the outcomes, and a hand-off card that never carries a secret." +); + +const EXCERPT = `"rules": [ + { "name": "first name", "id": "^FstName$", "do": { "text": "{{first_name}}" } }, + { "name": "net income", "label": "net income|income \\\\(loss\\\\)", + "types": ["text", "tel", "number"], "do": { "text": "{{net_income}}" } }, + { "name": "declaration", "label": "perjury|i declare|under penalty", + "types": ["checkbox"], "do": { "gate": "declaration" } } +], +"steps": [ + { "id": "declaration", "kind": "declare", + "statement": "perjury|i declare|under penalty", + "why": "Ticking this box is the representative stating, under penalty of perjury, that what was entered is true." } +]`; + +const RULES: Array<[string, string]> = [ + ["Shows the errand first", "Title, publisher, every gate with its reason, every input with its sensitivity and source. A file whose hash changed is shown again before it runs."], + ["Never guesses", "Fields are matched by id first and label second. A required field no rule fills stops the run."], + ["Never performs a gate", "Declarations need the person's consent for this run. Identity proofing and letters are theirs, and so are captchas on any sensitive site. Codes come only through a declared relay."], + ["One shared secret per run", "A figure from a return is submitted once. A rejection ends the run and lists the other candidates; a person picks the next one."], + ["Keeps documents and values in place", "Extraction is local. Values go to the site's fields and the vault; logs hold fields, never values; cards hold steps, never secrets."] +]; + +export default function OpenErrandPage(): ReactNode { + return ( + +
+
+

LogicSRC standards surface

+

OpenErrand

+

+ One JSON file that describes an errand a person runs on a website that has no API: + registering for a tax account, downloading a transcript, renewing a licence. A person + reads it and knows every value it will send and every statement it will ask them to + make. An agent runs the same file in a headless browser and stops exactly where a + person is needed. +

+
+

+ People already automate these sites, with a script nobody else can read or an agent + left to guess. The script hides what it sends. The agent ticks a penalty-of-perjury box + because the form would not submit without it, and tries a second figure when the first + is rejected. OpenErrand writes the errand down: the fields it fills and with what, the + values that are secret, the steps that belong to a person, and the point where it stops. +

+

+ Status: 0.1. A generic runner, logicsrc errand run from{" "} + @logicsrc/openerrand, is in progress. The worked example + transcribes the rule table of ftb in cli-tools, which registers + and activates MyFTB accounts at the California Franchise Tax Board. An excerpt: +

+
{EXCERPT}
+
+ +
+
+

Steps and human gates

+

+ Five of the seven step kinds are gates: steps only a person can take. A runner hands + them over and never performs them, and each carries a why a + person reads. +

+
+ + + {STEPS.map(([kind, what]) => ( + + + + + ))} + +
+ {kind} + {what}
+
+ +
+
+

Inputs

+

Every input has a sensitivity class, which decides where its value may appear.

+
+ + + + + + + + + {SENSITIVITY.map(([name, where]) => ( + + + + + ))} + +
classwhere it may appear
+ {name} + {where}
+

And an ordered list of sources, tried until one yields a value:

+ + + {SOURCES.map(([name, what]) => ( + + + + + ))} + +
+ {name} + {what}
+
+ +
+
+

What a runner promises

+

Thirteen rules in the specification; these are the ones that keep a person safe.

+
+ + + {RULES.map(([rule, meaning]) => ( + + + + + ))} + +
+ {rule} + {meaning}
+
+ +
+
+

Discovery

+

+ A publisher lists its errands at /.well-known/openerrand.json, + each with the gates it will ask of a person. An errand is verified when it comes from + that publisher's origin, and site-endorsed only when the site itself serves the + index. This site publishes the worked example at{" "} + + /.well-known/openerrand.json + + . +

+
+
+ +
+
+

Not

+
+

+ Not a way around a check: a runner may present a normal browser user agent and nothing more, with no fingerprint spoofing, no stealth plugins and no challenge solving. + Not for someone else's account. Not an API: when a site has one, use{" "} + OpenConnection,{" "} + OpenAccess or OpenSaaS. Not a + scraper, not a test framework, not a credential store, not legal or tax advice. +

+
+ +
+
+

Where everything lives

+
+
    +
  • + Specification: the file, inputs, field rules, + steps, the five gates, outcomes and retry, outputs, hand-off cards, discovery, thirteen + runner rules, and the MyFTB business registration as a worked example +
  • +
  • + JSON Schemas @logicsrc/schemas/openerrand and{" "} + openerrand-index, checked by{" "} + @logicsrc/validators +
  • +
  • + OpenCreds, the vault an errand writes logins to;{" "} + OpenFleet, the record an agent runs an errand under +
  • +
+
+
+ ); +} diff --git a/apps/logicsrc-web/src/lib/specs.ts b/apps/logicsrc-web/src/lib/specs.ts index 748ad1f..c8aa571 100644 --- a/apps/logicsrc-web/src/lib/specs.ts +++ b/apps/logicsrc-web/src/lib/specs.ts @@ -118,7 +118,7 @@ export const FAMILIES: Family[] = [ name: "Agents and process", line: "How agents coordinate, settle, stream, and how the software that serves them gets built", blurb: - "The lifecycle for building software when agents work in parallel and CI is the only gate, the requirement document an agent can execute, the settlement and proof layer under a peer-to-peer swarm, a lossless byte-stream envelope, the five nouns a shared ontology needs, the record an agent session carries about who spawned it and under what ceiling, and the one script that puts an application into service on the box it runs on.", + "The lifecycle for building software when agents work in parallel and CI is the only gate, the requirement document an agent can execute, the settlement and proof layer under a peer-to-peer swarm, a lossless byte-stream envelope, the five nouns a shared ontology needs, the record an agent session carries about who spawned it and under what ceiling, the one script that puts an application into service on the box it runs on, and the errand file an agent runs on a website with no API while handing every legal, identity and mail step to a person.", specs: [ s("asdlc", "ASDLC", "The Agentic Software Development Lifecycle: nine phases, four conformance levels and the ratchet rule"), s("openabtest", "OpenABTest", "Portable experiments with sticky assignments, distinct exposure and conversion events, and reconciled profit accounting", { landing: undefined, status: "draft" }), @@ -128,6 +128,7 @@ export const FAMILIES: Family[] = [ s("openontology", "OpenOntology", "Five nouns for a shared ontology, with governance and interoperability notes"), s("openfleet", "OpenFleet", "Agents under a human: the record a session carries about who spawned it, for what and under what ceiling, and the ledger its sysop reads"), s("openinstall", "OpenInstall", "One idempotent bin/install.sh in every repository that puts the app into service on the box it runs on: runtime, database, build, systemd, nginx and TLS, a health check, and exit codes a deployer can roll back on"), + s("openerrand", "OpenErrand", "One JSON file for an errand on a website with no API: inputs and where each comes from, field rules matched by id then label, human gates a runner never automates, outcomes, a never-retried shared secret, and a hand-off card with no secrets"), s("openspec", "OpenSpec.dev comparison", "How LogicSRC compares with OpenSpec.dev, and the compatibility mode", { doc: "/docs/openspec-comparison" }) ] } diff --git a/docs/openerrand.md b/docs/openerrand.md new file mode 100644 index 0000000..cc9530d --- /dev/null +++ b/docs/openerrand.md @@ -0,0 +1,679 @@ +# OpenErrand + +OpenErrand is one JSON file that describes an errand a person runs on a website that has no API: registering for a tax account, downloading a transcript, renewing a licence. It names the site and the pages to start from, the inputs and where each may come from, the rules that fill each form field, the steps a runner must hand to a person and never automate, what success and rejection look like on the page, what is kept afterwards and where, and the card a person gets when the errand has to wait for the post. A person reads the file before running it and knows every value it will send and every statement it will ask them to make; an agent runs the same file in a headless browser and stops exactly where a person is needed. It is maintained by Profullstack, Inc. as part of the LogicSRC open-standards surface. + +Status: **0.1**. A description of an errand a runner already performs, published so others can write errands, review them and run them. + +Slug: `openerrand` + +## The problem + +Government and institutional sites are where people spend the hours nobody wants to spend: a tax board, a licensing office, a benefits portal. Most have no API and no OAuth. An account is a browser form checked against a figure from last year's return, then a code by text, then a PIN by letter. The forms change without notice, a bot check sits in front of them, and one wrong answer can lock the account. + +People already automate these. They do it with a script on one laptop that nobody else can read, or with an agent told "register me at the FTB" and left to guess. The script hides what it sends. The agent guesses at fields it has never seen, ticks a penalty-of-perjury box because the form would not submit without it, and tries a second figure when the first is rejected. Neither can be reviewed before it runs, and neither can be shared. + +What is missing is the errand written down: the fields it fills and with what, the values that are secret, the steps that belong to a person, and the point where it stops. Written down, an errand can be read by the person it acts for, reviewed by someone else, published, and run by any runner that keeps the same promises. + +## Terms + +- An **errand** is one task on one site that ends in an outcome a person cares about: an account registered, an account activated, a document downloaded. Its **errand file** is the JSON document this specification describes. +- A **site** is the website the errand runs on, named by its origins. +- The **principal** is the person or organisation the errand acts for, and whose data it uses. +- A **runner** is the program that reads an errand file and drives a browser through it. +- An **input** is one value the errand needs: a name, a ZIP code, a figure from a return, a password. +- A **field** is one form control on a page, as a runner reads it: its id, name, type, the label a person would read, whether it is required, and its options. +- A **rule** decides what to do with a field. +- A **step** is one kind of thing that happens during a run: a form page, a wait, or a **gate**. +- A **gate** is a step only a person can take. A runner hands it to a person and never performs it. +- An **outcome** is what the site says at the end: success, rejected, or waiting. +- A **hand-off card** is the note a person gets when the errand needs them later. It carries steps and never a secret. +- A **publisher** is whoever writes and serves an errand file. It is usually not the site. + +## The errand file + +A JSON document, served as `application/json`, conventionally named `.json`. The smallest valid errand is a site, one page step and one outcome: + +```json +{ + "type": "logicsrc.openerrand", + "version": "0.1", + "name": "example-contact-form", + "title": "Send the contact form", + "site": { "name": "Example", "origins": ["https://example.com"], "start": ["https://example.com/contact"] }, + "steps": [{ "id": "form", "kind": "page" }], + "outcomes": [{ "name": "sent", "kind": "success", "text": "thank you" }] +} +``` + +The top-level keys: + +| key | meaning | +| --- | --- | +| `type` | Always `logicsrc.openerrand`. | +| `version` | `0.1`. | +| `id` | The https URL the file is published at. The dedupe key for an index or a directory. | +| `name` | A slug, unique within the publisher: `ftb-register-business`. A mail gate's `resume` names another errand by it. | +| `title` | What a person reads first: `Register a MyFTB business account`. | +| `description` | A paragraph a person reads before running it. | +| `publisher` | The publisher's [OpenProfile.md](/openprofile) URL. | +| `updated` | When anything in the file last changed, ISO 8601. | +| `reference` | A URL for the runner or the code the errand was taken from. | +| `principal` | `self`: the person running it is the principal. `represented`: they act for the principal with authority to, as a corporation's officer does for the corporation. | +| `site` | `name`, `sector` (`government`, `tax`, `financial`, `healthcare`, `identity-provider`, `commercial` or `other`: who runs the site, which decides whether a captcha solver may ever be allowed), `origins` (the https origins the runner may navigate), `start` (the URLs a run opens, in order of preference), `terms` (the site's terms of use). | +| `limits` | `pages` (the most pages one run may submit, default 15), `page_timeout` (default `PT30S`), `same_page` (how many times the same URL may come back before the run stops as a loop, default 2). | +| `inputs` | The values the errand needs, by name. | +| `rules` | The field rules, applied on every page step. | +| `steps` | Page steps, waits and gates. | +| `submit` | How the runner finds a page's forward button. | +| `outcomes` | What success, rejection and waiting look like. | +| `retry` | What the runner may do again. | +| `outputs` | What is kept after a success, and where. | +| `handoffs` | The cards a person gets, by id. | +| `metadata` | Anything the publisher wants to add. Every other unknown key is an error, so a typo in a gate is caught before a run. | + +Patterns throughout are ECMAScript regular expressions, matched case-insensitively. Durations are ISO 8601 (`PT15M`, `P21D`). + +## Inputs + +Each input has a `type` (`string`, `integer`, `number`, `email`, `date`, `boolean`, or `qa-set` for security questions and their answers), a `sensitivity`, and an ordered list of `sources`. Optional keys: `label` (what a person is shown), `required`, `pattern` (a value must match it), `max_length` (the value is cut to it when filled, because the site's box takes no more), `count` (for a `qa-set`), and `role`. + +### Sensitivity + +| class | what it is | where it may appear | +| --- | --- | --- | +| `public` | Anything already public or harmless: a corporation number on the state register, a tax year. | Logs, the terminal, hand-off cards, the run record. | +| `personal` | Identifies a person: a name, an address, an email, a phone number, a user name. | The terminal of the principal, the site's own fields, the vault. Never in a log, never on a card. | +| `secret` | Proves who someone is or opens an account: a password, a security answer, an SSN, a figure from a return used as a shared secret, a PIN. | The site's own field and the vault, and nowhere else. Masked on the terminal as `••••`. | + +A runner treats an input with no stated sensitivity as an error, not as public. + +### Sources + +Sources are tried in order and the first that yields a value wins. + +| source | where the value comes from | +| --- | --- | +| `document` | Extracted on the principal's machine from the principal's own files: `form` and `field` as printed (`CA 100S`, `line 20`), an optional `match` for the printed label, `pick` (`newest`, `oldest`, `each`), `years` (`back`, how many closed years count, and `current`, whether the year in progress does), and a `transform`. The runner records which file and page each value came from. The document never leaves the machine. | +| `vault` | A key in the principal's vault, such as an [OpenCreds](/opencreds) vault. Listed before `generate`, so a second run reuses the login the first one made. | +| `prompt` | Asked of a person at run time, with `ask` as the question. A secret prompt does not echo. | +| `generate` | A fresh random value from the runner's cryptographic generator: `length`, `classes` (`lower`, `upper`, `digit`, `special`) with at least one character of each, `special` for the characters the site accepts. A generated credential is written to the vault on success. | +| `derive` | Another input, with a `transform`: the numbers in a street address are `{ "from": "derive", "input": "street", "transform": "digits" }`. | +| `candidate` | One `part` (`year`, `form`, `field`, `source`) of the candidate chosen for a shared-secret input, so the tax year sent is the year the figure came from. | +| `literal` | A fixed `value`. | + +Transforms are `digits` (keep the digits), `whole` (whole units, a leading minus for a loss, no separators), `upper`, `lower`, `trim`, `first:N` and `last:N`. + +### Roles + +- `shared-secret`: a value the site checks against its own records to prove the principal is who they say. Always `secret`. Its `document` sources produce a ranked list of candidates (sources in order, newest year first within each), and a run submits exactly one of them. See [Retry](#outcomes-and-retry). +- `credential`: a login the errand creates or uses. Written to the vault when `outputs.vault` names it. +- `identifier`: a number that names the principal at the site, such as an account number. + +## Field rules + +A runner reads every visible, enabled control on a page into a field: `id`, `name`, `type`, `label` (the `label for`, then `aria-label`, then a wrapping label, then the group's legend, then the placeholder), `required`, and `options` for a select. A rule is: + +```json +{ "name": "zip", "label": "zip|postal", "types": ["text", "tel", "number"], "do": { "text": "{{zip}}" } } +``` + +`name` appears in logs and errors. `id` is tested against the field's id; `label` against the field's id, name and label joined by spaces. `types` limits the rule to those field types; a rule without `types` fits any. At least one of `id` and `label` is required. + +### Matching + +1. **Id first, label second.** The runner tries every rule with an `id` before any rule's `label`. Ids are what the publisher saw on the site and are exact; labels cover pages not yet seen. +2. **The first fitting rule decides.** An id match is final even when its action is `skip`. A label match whose action cannot act (no option matches, the radio's label is the wrong one, the input has no value) lets the next rule try. +3. **A page step's own `rules` come before the errand's.** +4. **Choices before text.** Selects, radios and checkboxes are filled first; then the page is read again and text boxes are filled, because choosing a security question or a form type changes what the page asks next. +5. **An unmatched required field stops the run.** The runner names the field, its label and type, and the page. When the page step says `"unmatched": "ask"` and a person is at a terminal, the runner may ask them instead. It never guesses. + +### Actions + +| action | what the runner does | +| --- | --- | +| `text` | Sets the field to the template, with `{{input}}` replaced by the input's value. `split` (`[3, 2, 4]`) fills a value spread over several boxes, taking the part from the digit at the end of the field's id. | +| `select` | Chooses the first option whose text or value matches one of the patterns, tried in order. `{{input}}` inside a pattern is replaced by the value with regular-expression characters escaped. | +| `check` | `true` ticks the box or radio. `{ "label": pattern }` ticks a radio only when its own label matches. | +| `skip` | Leaves the field as it is. | +| `choose` | For a `qa-set` input: chooses the first question not yet used and records it with its answer. | +| `answer` | For a `qa-set` input: types the answer to the question chosen in the select before the box, or the question the page prints. | +| `gate` | Hands the field to the gate step with that id. | + +The runner sets a value with the element's native setter and then fires `input`, `change` and `blur`, so a page's own validation sees it. + +## Steps + +On each page the runner takes the first step whose `match` fits (`url`, `title`, `text` as patterns, `selector` as a CSS selector that must find an element; all that are given must fit). A page step without `match` fits any page and is the fallback. Gates are also reached from a rule's `gate` action and from an outcome's `then`. + +| kind | what it is | +| --- | --- | +| `page` | A form page: fill it from the rules, then press its forward button. | +| `wait` | An interstitial the page clears by itself, such as a proof-of-work bot check. The runner polls every `poll` until `match` no longer fits, and stops when `timeout` passes. It never solves or bypasses the check. | +| `declare` | A legal attestation. A gate. | +| `identity-proofing` | Proving who you are to an identity provider: a selfie, a video call, a document scan. A gate. | +| `code` | A one-time code sent to the principal. A gate. | +| `mail` | A letter the site posts to the principal. A gate. | +| `captcha` | A test meant to tell a person from a program. A gate, unless the errand allows a solver where [the captcha rules](#captcha) permit one. | + +## Human gates + +A gate is first-class because it is the part of an errand that matters most and the part an automation is most tempted to fake. Every gate step carries `why`, the sentence a person reads about what is being asked of them. + +### `declare` + +A box or button by which the principal states something under penalty of perjury, or otherwise attests that what was entered is true. `statement` is a pattern for the text. + +The runner ticks it only with the principal's consent for this run, given after the runner has shown the values that will be attested, secrets masked. Consent is a flag the person types (`--declare`), a button they press, or an answer at a prompt. It is never read from a configuration file, an environment variable, the errand file, a saved default, or another agent's say-so. Without it the runner stops on that page, prints the statement word for word, and submits nothing. + +### `identity-proofing` + +A redirect to an identity provider (ID.me, Login.gov, a bank's own check) that asks for a face, a video call or a scan of an ID. `provider` names it and `origins` lists its origins. + +A runner does not automate any of it. It does not click, type or run script on a page from those origins, does not supply a camera, microphone or file to the provider (no virtual camera, no recorded video, no emulated device), does not read what the provider shows, and does not ask a model to act as the person. It hands the browser session to the principal in a visible window, or ends the run with a hand-off card when no person is present. It resumes only when the page is back on one of `site.origins`, before `timeout`. + +### `code` + +A one-time code sent by `sms`, `email`, `voice` or an authenticator `app`. `relay` lists how it reaches the runner: `terminal` (typed at a prompt), `file` (written to a file the runner names and watches, by whoever holds the phone or by an agent the principal set up to relay their messages), or `page` (a box in the runner's own interface). `pattern` checks the code and `timeout` is how long the runner keeps the browser session open waiting for it. + +The runner uses the code once and never logs it, stores it or shows it again. When a page offers a choice between a text and a call, a rule chooses the channel the relay can carry. + +### `mail` + +A letter, usually with a PIN, that only the addressee can read. `what` names it, `arrives` says when, `expires` is how long it is good for from the run. `resume` names the errand that continues once it comes, and `input` the input that errand takes from the letter. + +A mail gate ends the run with outcome kind `waiting`. The runner delivers the `handoff` card, on the surface described in [Hand-off cards](#hand-off-cards), with `{{expires_on}}` set to the run date plus `expires`, and the continuing errand marks the card done when it succeeds. + +### `captcha` + +By default a runner shows the page to the principal in a visible window, or stops. It does not send the captcha to a solving service, a model, or a person paid to solve them. + +`solver` on the step is `forbidden`, the default, or `allowed`. A solver is never allowed on a government, tax, financial, healthcare or identity-provider site, as `site.sector` states, and never on an errand with a `declare` or `identity-proofing` step or any `secret` input, whatever its sector. Elsewhere an errand may say `"solver": "allowed"` explicitly, and a runner that then uses a solver logs every use: the time, the page URL and the service, never the image or the answer. A validator rejects `allowed` on an errand in the forbidden set, and on an errand that does not state its `site.sector`. + +A `wait` step is not a captcha: a proof of work the page's own script solves asks nothing of a person, and waiting for it is all a runner does. + +## Outcomes and retry + +Before filling each page, the runner reads the page's error messages (or, when there are none, its text) and tests every outcome of kind `rejected` first, then the others in order. `text` and `url` are patterns; when both are given both must fit. `then` names the step that follows an outcome, as the PIN letter follows a registration. + +| kind | meaning | +| --- | --- | +| `success` | The errand did what it says. | +| `rejected` | The site said no. The run ends. | +| `waiting` | The errand is done for now and continues when a mail gate's letter arrives. | + +A runner adds one outcome of its own, `stopped`, with the reason: `unmatched` (a required field nothing fills), `loop` (the same page came back `limits.same_page` times), `pages` (`limits.pages` passed), `timeout`, `gate` (a gate was refused or nobody was there), `off-site` (a navigation left `site.origins`), or `no-forward` (no forward button, with the buttons seen). + +`retry` says what may be done again: + +- `shared_secret` is always `never`, and is `never` when absent. A run submits one candidate for a shared-secret input. When the site rejects it, the run ends, the runner lists the other candidates with where each came from, and a person chooses the next one for the next run. A runner never submits a second candidate on its own, never cycles through them, and never sends a list. Sites lock accounts after a few wrong answers, and the person, not the runner, decides whether a lock is worth risking. +- `page_errors` is `rejected` (the default) or `continue`. With `rejected`, a validation error shown on any page after the first ends the run as rejected instead of being answered by a second guess. + +A runner reports the run as a record an agent can read, with no input values in it: + +```json +{ + "errand": "https://logicsrc.com/examples/openerrand/ftb-register-business.json", + "outcome": "registered", + "kind": "success", + "at": "2026-10-04T17:20:11Z", + "page": "https://webapp.ftb.ca.gov/MyFTBAccess/Registration/Confirmation", + "candidate": { "year": 2025, "form": "CA 100S", "field": "line 20" }, + "handoff": "pin-letter/7f3k2q" +} +``` + +## Outputs + +`outputs.vault` writes credentials to the principal's vault when the outcome named in `when` happens: `keys` maps each vault key to a template. The vault is written before the runner reports success. A runner with no vault keeps them in a state file only the principal can read (mode 0600) and says so. + +`outputs.downloads` files what the site hands over: each entry has a `match` (`url`, `filename` as patterns, `type` as a media type) and a `to` path template, such as `~/Documents/irs/{{tax_year}}/account-transcript.pdf`. A runner checks that a file is what `type` says before filing it, and never overwrites a file with different bytes. + +Inputs never go anywhere else. A runner does not send them to a model, a telemetry endpoint, or a log. Its page log records each page's fields (selector, type, label, required, options) and never their values, which is what a publisher needs to fix a rule. + +## Hand-off cards + +A card is what a person gets when the errand needs them later: a title, numbered steps, a URL to `open`, and a `command` to run. The run record names it by an opaque id (`pin-letter/7f3k2q`), never by a URL on another service. + +A card is delivered only on the surface that owns the errand's data. For a tax or finance errand that is the principal's finance app, through its own CLI, PWA, MCP server or API (CoinPay, for example), or the runner's own terminal. It is never delivered through a social network, a promotion or marketing tool, a posting or scheduling service, or any other third party, and never sent to anyone but the principal. A card for an errand with any personal or secret input does not leave that surface at all: no copy, link, notification text or preview of it is handed to another service. + +Its templates may name `{{expires_on}}`, `{{errand.title}}`, `{{site.name}}` and public inputs, and nothing else. A runner refuses to render a card that names a personal or secret input, and a validator rejects the file. No PIN, password, SSN, figure from a return or address ever goes on one. + +## Discovery + +An errand file can be published at any https URL. A publisher lists its errands at `/.well-known/openerrand.json` on its own origin: + +```json +{ + "type": "logicsrc.openerrand-index", + "version": "0.1", + "publisher": "https://logicsrc.com/.well-known/openprofile.md", + "updated": "2026-10-04T00:00:00Z", + "errands": [ + { + "url": "https://logicsrc.com/examples/openerrand/ftb-register-business.json", + "name": "ftb-register-business", + "site": "https://webapp.ftb.ca.gov", + "title": "Register a MyFTB business account", + "gates": ["declare", "code", "mail"], + "updated": "2026-10-04T00:00:00Z" + } + ] +} +``` + +`gates` lets a person see, before fetching anything else, what an errand will ask of them. A page can also point at an index with `` or a `Link: <...>; rel="openerrand"` header. + +An errand is **verified** when its file came from the origin of the index that lists it: that publisher stands behind it. It is **site-endorsed** only when the site itself serves the index, because only then has the institution said this is how its forms work. A runner shows both before the first run. + +## The rules + +A runner conforms to OpenErrand 0.1 when it does all of these. + +### 1. It shows the errand before it runs it + +The title, the publisher, whether the file is verified or site-endorsed, every gate with its `why`, and every input with its sensitivity and source. The file a person approved is the file that runs: a runner records the file's SHA-256 and shows the change before running a file whose hash differs. + +### 2. It stays on the site + +It navigates only to `site.origins` and to the origins of an identity-proofing gate it has handed to a person. A redirect anywhere else ends the run as `stopped: off-site`. + +### 3. It matches by id, then by label, and never guesses + +As in [Matching](#matching). A required field no rule fills stops the run, or is asked of a person at a terminal when the step allows it. + +### 4. It never performs a gate + +A `declare` box is ticked only on the principal's consent for this run. Identity proofing and letters are the person's, and so is a captcha unless the errand allows a solver where [the captcha rules](#captcha) permit one. A code comes only through a declared relay. + +### 5. It submits one shared secret per run + +And never retries one. A rejection ends the run and lists the other candidates for a person to choose from. + +### 6. It keeps documents on the machine + +Extraction runs locally. A document, or any page of one, is never uploaded, sent to a model, or copied off the machine by the runner. + +### 7. It keeps each value in its class + +`public` may go anywhere; `personal` goes to the principal's terminal, the site's fields and the vault; `secret` goes to the site's field and the vault, masked everywhere else. + +### 8. It logs fields and never values + +The page log holds selectors, types, labels, required flags and options. A value never appears in a log, an error, or the run record. + +### 9. It puts nothing personal or secret on a card, and keeps the card where the data lives + +Only built-ins and public inputs go on a card, and the card is delivered only on the surface that owns the errand's data (for a tax or finance errand, the principal's finance app or the runner's own terminal), never through a social, promotion or third-party posting service, as in [Hand-off cards](#hand-off-cards). + +### 10. It writes credentials before it reports success + +To the vault in `outputs.vault`, or to a 0600 state file when there is none. A login that exists only in a terminal's scrollback is a login lost. + +### 11. It may look like a browser, and never defeats a check + +A runner may run headless and present a normal desktop browser user agent, for example by dropping `HeadlessChrome` from it. That is as far as it goes: no fingerprint spoofing beyond the user-agent string, no stealth plugins, and no solving, skipping or evading a bot challenge. A challenge the browser completes on its own, such as a proof-of-work interstitial, is a `wait` step, polled until it clears or times out. Any other challenge is a `captcha` gate. + +### 12. It has a dry run + +A dry run fills every page up to the first page that holds a `declare` gate or a shared-secret input, prints what it would send there with secrets masked and the declaration word for word, and stops before that page's forward button. Nothing a site keeps is created by a dry run. + +### 13. It stops on a loop + +The same URL `limits.same_page` times, or more than `limits.pages` pages, ends the run as stopped, with the page log path. + +## Worked example + +Registering a MyFTB account for a California S corporation at the Franchise Tax Board. FTB has no API: the account is a browser registration checked against a figure from a filed Form 100S, a code texted to the representative's phone, and a PIN mailed to the address on file. `ftb` in [cli-tools](https://github.com/profullstack/cli-tools/pull/125) performs it today, and this file transcribes its rule table, gates and outcomes. The file holds no personal data: every value comes from the principal's own returns, vault or terminal at run time. + +```json +{ + "type": "logicsrc.openerrand", + "version": "0.1", + "id": "https://logicsrc.com/examples/openerrand/ftb-register-business.json", + "name": "ftb-register-business", + "title": "Register a MyFTB business account", + "description": "Creates a MyFTB account for a California S corporation at the Franchise Tax Board, proving the business with a figure from a filed Form 100S. FTB then mails a PIN; activation is a second errand.", + "publisher": "https://logicsrc.com/.well-known/openprofile.md", + "updated": "2026-10-04T00:00:00Z", + "reference": "https://github.com/profullstack/cli-tools/pull/125", + "principal": "self", + "site": { + "name": "California Franchise Tax Board (MyFTB)", + "sector": "tax", + "origins": ["https://webapp.ftb.ca.gov"], + "start": ["https://webapp.ftb.ca.gov/MyFTBAccess/Registration/NewAccount"] + }, + "limits": { "pages": 15, "page_timeout": "PT30S", "same_page": 2 }, + "inputs": { + "email": { + "label": "Email address FTB writes to", + "type": "email", + "sensitivity": "personal", + "required": true, + "sources": [{ "from": "prompt" }] + }, + "phone": { + "label": "Mobile number FTB texts a verification code to", + "type": "string", + "pattern": "^[0-9]{10}$", + "sensitivity": "personal", + "required": true, + "sources": [{ "from": "prompt" }] + }, + "first_name": { + "label": "Representative's first name", + "type": "string", + "max_length": 11, + "sensitivity": "personal", + "sources": [{ "from": "document", "form": "CA 540", "field": "first name", "pick": "newest" }] + }, + "last_name": { + "label": "Representative's last name", + "type": "string", + "max_length": 13, + "sensitivity": "personal", + "sources": [{ "from": "document", "form": "CA 540", "field": "last name", "pick": "newest" }] + }, + "street": { + "label": "Street address on the newest return", + "type": "string", + "sensitivity": "personal", + "sources": [{ "from": "document", "form": "CA 540", "field": "street address", "pick": "newest" }] + }, + "address_numbers": { + "label": "The numbers in the address on file", + "type": "string", + "sensitivity": "personal", + "sources": [{ "from": "derive", "input": "street", "transform": "digits" }] + }, + "zip": { + "label": "ZIP code on file", + "type": "string", + "sensitivity": "personal", + "sources": [{ "from": "document", "form": "CA 540", "field": "ZIP code", "pick": "newest", "transform": "first:5" }] + }, + "corp_id": { + "label": "California corporation number", + "type": "string", + "sensitivity": "public", + "sources": [{ "from": "document", "form": "CA 100S", "field": "California corporation number", "pick": "newest" }] + }, + "tax_year": { + "label": "Tax year of the return the shared secret comes from", + "type": "integer", + "sensitivity": "public", + "sources": [{ "from": "candidate", "input": "net_income", "part": "year" }] + }, + "net_income": { + "label": "Net income for tax purposes, whole dollars", + "type": "integer", + "sensitivity": "secret", + "role": "shared-secret", + "sources": [ + { "from": "document", "form": "CA 100S", "field": "line 20", "match": "net income for tax purposes", "years": { "back": 5, "current": false }, "transform": "whole" }, + { "from": "document", "form": "CA 100S", "field": "line 15", "match": "net income \\(loss\\) for state purposes", "years": { "back": 5, "current": false }, "transform": "whole" } + ] + }, + "username": { + "label": "MyFTB user name", + "type": "string", + "sensitivity": "personal", + "role": "credential", + "sources": [ + { "from": "vault", "key": "FTB_BUSINESS_USERNAME" }, + { "from": "generate", "length": 15, "classes": ["lower", "digit"] } + ] + }, + "password": { + "label": "MyFTB password", + "type": "string", + "sensitivity": "secret", + "role": "credential", + "sources": [ + { "from": "vault", "key": "FTB_BUSINESS_PASSWORD" }, + { "from": "generate", "length": 24, "classes": ["lower", "upper", "digit", "special"], "special": "!#$*@" } + ] + }, + "security": { + "label": "Three security questions and their answers", + "type": "qa-set", + "count": 3, + "sensitivity": "secret", + "role": "credential", + "sources": [ + { "from": "vault", "key": "FTB_BUSINESS_SECURITY_ANSWERS" }, + { "from": "generate", "length": 10, "classes": ["lower", "digit"] } + ] + } + }, + "rules": [ + { "name": "read terms", "id": "^ReadTerms$", "types": ["checkbox"], "do": { "check": true } }, + { "name": "accept terms", "id": "^AcceptTerms$", "types": ["checkbox"], "do": { "check": true } }, + { "name": "first name", "id": "^FstName$", "do": { "text": "{{first_name}}" } }, + { "name": "middle initial", "id": "^MInitial$", "do": { "skip": true } }, + { "name": "last name", "id": "^LstName$", "do": { "text": "{{last_name}}" } }, + { "name": "suffix", "id": "^Sffx$", "do": { "skip": true } }, + { "name": "user name again", "id": "^ReUserName$", "do": { "text": "{{username}}" } }, + { "name": "user name", "id": "^UserName$", "do": { "text": "{{username}}" } }, + { "name": "email again", "id": "^ReEmail$", "do": { "text": "{{email}}" } }, + { "name": "email", "id": "^Email$", "do": { "text": "{{email}}" } }, + { "name": "password again", "id": "^RePassword$", "do": { "text": "{{password}}" } }, + { "name": "password", "id": "^Password$", "do": { "text": "{{password}}" } }, + { "name": "foreign number", "id": "^Phone_Foreign$", "do": { "skip": true } }, + { "name": "foreign address", "id": "^Address_Foreign$|^Address_No(MailAddress|PostalCode)$", "do": { "skip": true } }, + { "name": "security question", "label": "question", "types": ["select-one"], "do": { "choose": "security" } }, + { "name": "security answer", "label": "question|answer", "types": ["text", "password"], "do": { "answer": "security" } }, + { "name": "role", "label": "individual|business representative", "types": ["radio"], "do": { "check": { "label": "^\\s*business representative" } } }, + { "name": "zip", "label": "zip|postal", "types": ["text", "tel", "number"], "do": { "text": "{{zip}}" } }, + { "name": "address numbers", "label": "numbers in (the |your )?(business )?(mailing )?address", "types": ["text", "tel", "number"], "do": { "text": "{{address_numbers}}" } }, + { "name": "tax year", "label": "year (of|on) the tax return|tax year", "types": ["select-one"], "do": { "select": ["^\\s*{{tax_year}}\\s*$"] } }, + { "name": "tax year", "label": "year (of|on) the tax return|tax year", "types": ["text", "tel", "number"], "do": { "text": "{{tax_year}}" } }, + { "name": "net income", "label": "net income|income \\(loss\\)", "types": ["text", "tel", "number"], "do": { "text": "{{net_income}}" } }, + { "name": "company type", "label": "type of company|company type|entity type", "types": ["select-one"], "do": { "select": ["^\\s*corporation\\s*$", "corporation"] } }, + { "name": "account number", "label": "account number|entity id|corporation (id|number)", "types": ["text", "tel", "number"], "do": { "text": "{{corp_id}}" } }, + { "name": "form type", "label": "form type|type of (tax )?(return|form)", "types": ["select-one"], "do": { "select": ["100\\s*S\\b"] } }, + { "name": "declaration", "label": "perjury|i declare|under penalty", "types": ["checkbox"], "do": { "gate": "declaration" } }, + { "name": "phone", "label": "phone number", "types": ["text", "tel", "number"], "do": { "text": "{{phone}}" } }, + { "name": "send a text", "label": "send me a text|text message", "types": ["radio"], "do": { "check": true } }, + { "name": "verification code", "label": "verification code|security code|one[- ]time|passcode|access code|enter (the )?code", "types": ["text", "tel", "number", "password"], "do": { "gate": "text-code" } } + ], + "steps": [ + { + "id": "bot-check", + "kind": "wait", + "match": { "title": "^Challenge Validation$", "selector": "#sec-cpt-if" }, + "timeout": "PT90S", + "poll": "PT3S", + "say": "FTB's bot check is a proof of work the page's own script solves; the runner waits for it." + }, + { + "id": "form", + "kind": "page", + "unmatched": "stop", + "say": "Every MyFTB registration page: terms, profile, security questions, role, address, shared secret, phone." + }, + { + "id": "declaration", + "kind": "declare", + "statement": "perjury|i declare|under penalty", + "why": "Ticking this box is the representative stating, under penalty of perjury, that what was entered is true. Only that person can make the statement." + }, + { + "id": "text-code", + "kind": "code", + "channel": "sms", + "relay": ["terminal", "file"], + "pattern": "^\\w{4,10}$", + "timeout": "PT15M", + "why": "FTB texts a code to the phone number given. Whoever holds the phone reads it out." + }, + { + "id": "pin-letter", + "kind": "mail", + "what": "MyFTB PIN letter", + "arrives": "5 to 10 business days, to the address FTB has on file", + "expires": "P21D", + "resume": "ftb-activate-business", + "input": "pin", + "handoff": "pin-letter", + "why": "FTB activates a new account with a PIN it sends by US Mail. Nobody but the addressee can read it." + } + ], + "submit": { + "labels": "^(submit|continue|next|log ?in|login|activate|send( code| me a code)?|verify|confirm)$", + "never": "^(back|cancel|end session|previous)$", + "ignore": "#timer, .modal" + }, + "outcomes": [ + { + "name": "rejected", + "kind": "rejected", + "text": "does not match our records|there is a problem|unable to (verify|process) your|account (is|has been) locked" + }, + { + "name": "registered", + "kind": "success", + "text": "registration confirmation|successfully (registered|created)|we will (mail|send) you a (letter|pin)|pin .*(mail|letter)", + "then": "pin-letter" + } + ], + "retry": { "shared_secret": "never", "page_errors": "rejected" }, + "outputs": { + "vault": { + "when": "registered", + "keys": { + "FTB_BUSINESS_USERNAME": "{{username}}", + "FTB_BUSINESS_PASSWORD": "{{password}}", + "FTB_BUSINESS_EMAIL": "{{email}}", + "FTB_BUSINESS_SECURITY_ANSWERS": "{{security}}" + } + } + }, + "handoffs": { + "pin-letter": { + "title": "FTB PIN letter: business MyFTB account", + "open": "https://webapp.ftb.ca.gov/MyFTBAccess/", + "steps": [ + "Watch the mail at the address FTB has on file for the MyFTB PIN letter (5 to 10 business days).", + "Activate before {{expires_on}}: the PIN expires 21 days after registration.", + "Run the command below yourself, with the PIN from the letter." + ], + "command": "ftb activate business --pin " + } + } +} +``` + +What a run looks like, for a fictional representative Jane Doe of 1234 Maple St, Sacramento, CA 95814, and her corporation, number 1234567. The candidates for the shared secret come first: + +``` +$ ftb secrets +Business (Form 100S), best first: + 2025 100S line 20 48210 corp 1234567 2025/100S.pdf p3 + 2025 100S line 15 51377 corp 1234567 2025/100S.pdf p3 + 2024 100S line 20 39875 corp 1234567 2024/100S.pdf p3 +``` + +A dry run walks the pages and stops on the one with the declaration: + +``` +$ ftb register business --email jane@example.com --phone 5555550100 --dry-run +Registering a business MyFTB account as jdoeb4k2x + shared secret: 2025 Form 100S line 20 = 48210 from 2025/100S.pdf p3 + address on file: 1234 / 95814 corp 1234567 + Registration https://webapp.ftb.ca.gov/MyFTBAccess/Registration/NewAccount + read terms + accept terms + -> Continue + ... + company type = Corporation + form type = 100S + tax year = 2025 + declaration + account number = 1234567 + net income = •••• +Dry run: stopped before the first Continue that sends anything to FTB. +``` + +Jane checks the values, then runs it with `--declare`. FTB texts a code; she types it at the prompt (or it is written to the code file). FTB answers with its confirmation, which matches the `registered` outcome; the runner writes the login to the vault, follows `then` to the `pin-letter` mail gate, keeps the card on the runner's own surface, and ends the run as waiting: + +``` +Registered: business MyFTB account jdoeb4k2x. FTB mails a PIN to the address on file. + login saved to the ftb vault (profullstack/prod) + PIN-letter card: pin-letter/7f3k2q (ftb status shows it) + When the letter comes: ftb activate business --pin +``` + +Had FTB answered "does not match our records", the run would have ended there with the 2024 figure listed as the next candidate, and nothing would have been retried. + +## A second errand, sketched + +The next errand planned is IRS.gov: sign in through ID.me and download account and return transcripts. It has not been run, and this fragment shows only what is new, the identity-proofing gate and the downloads: + +```json +{ + "steps": [ + { + "id": "id-me", + "kind": "identity-proofing", + "provider": "ID.me", + "origins": ["https://api.id.me", "https://account.id.me"], + "timeout": "PT30M", + "handoff": "id-me", + "why": "The IRS signs people in through ID.me, which may ask for a selfie or a video call. That is yours to do; the runner waits until you are back on irs.gov." + } + ], + "outputs": { + "downloads": [ + { "match": { "type": "application/pdf", "url": "transcript" }, "to": "~/Documents/irs/{{tax_year}}/transcript.pdf", "when": "downloaded" } + ] + } +} +``` + +## Schema + +The JSON Schemas are `logicsrc-openerrand.schema.json` and `logicsrc-openerrand-index.schema.json` in [`@logicsrc/schemas`](https://github.com/profullstack/logicsrc/tree/master/packages/schemas/schemas), exported as `@logicsrc/schemas/openerrand` and `@logicsrc/schemas/openerrand-index`. `@logicsrc/validators` adds the checks a schema cannot express: step, gate, outcome and card references resolve, every `{{template}}` names an input, a shared secret is `secret`, no card names a personal or secret input, and a captcha solver is allowed only outside the forbidden set. + +``` +npx @logicsrc/validators openerrand ftb-register-business.json +``` + +## Reference runner + +In progress: a generic runner, the `@logicsrc/openerrand` package in the LogicSRC repository, run as `logicsrc errand run `, reads an errand file and drives headless Chrome through it under the rules above. Until it ships, `ftb` in cli-tools is the runner the worked example was taken from; it has the same rule table compiled in rather than reading the file. + +## Not + +**Not a way around a check.** A runner may present an ordinary browser user agent and nothing more. There is no key for proxies or browser fingerprints, no gate a runner may perform, and a captcha solver only where [the captcha rules](#captcha) allow one. A site that wants a person gets one. + +**Not for someone else's account.** The principal is the person running it or someone they represent with authority. An errand run against a stranger's records is the fraud the site's checks exist to stop. + +**Not an API.** When a site has one, use it: [OpenConnection](/openconnection) for a token you paste, [OpenAccess](/openaccess) for a grant, [OpenSaaS](/opensaas) for the actions a subscription service publishes. An errand is for sites that offer a person a form and nothing else. + +**Not a scraper.** An errand does one task for its principal and stops. It does not crawl, and it reads nothing the principal could not read in their own browser. + +**Not a test framework.** Playwright and Selenium test a site its owner controls. An errand drives a site its publisher does not control, so it stops on anything it has not seen instead of failing a test. + +**Not a credential store.** Logins go to a vault such as [OpenCreds](/opencreds); the errand file names keys, not values. + +**Not legal or tax advice.** A declaration is the principal's statement, and an errand only carries it to the form. + +## Relationship to other standards + +- [OpenSaaS](/opensaas) describes each action as a `page` for a person and an `api` for an agent. An errand is what an agent does when there is only the `page`. +- [OpenCreds](/opencreds) is the vault `outputs.vault` writes to and `vault` sources read from. +- [OpenFleet](/openfleet) is the record an agent session carries; an agent running an errand runs it under that record, and the gates are where it hands back to its human. +- [OpenProfile.md](/openprofile) names the `publisher`. +- Selenium IDE's `.side` files and Playwright's recorded scripts replay clicks on selectors. An errand describes fields by meaning, with a fallback from id to label, so it survives a reworded page or stops on one, and it says which steps are a person's. +- The CNCF Open Workflow Specification (formerly Serverless Workflow) orchestrates services through their APIs. An errand drives a browser through pages; the two do not overlap, which is also why this is not called a workflow. + +## Version history + +| Version | Date | Change | +| --- | --- | --- | +| 0.1 | 2026-10-04 | First publication: the errand file, inputs with three sensitivity classes and seven sources, field rules matched by id then label, page and wait steps, five human gates (`declare`, `identity-proofing`, `code`, `mail`, `captcha`), outcomes, the never-retry rule for shared secrets, vault and download outputs, hand-off cards with no personal data, the publisher index at `/.well-known/openerrand.json`, thirteen runner rules (among them: a normal browser user agent and nothing more), the captcha solver policy keyed on `site.sector`, and the MyFTB business registration as the worked example. | + +## License + +The specification text is CC BY 4.0. Serve it, copy it, extend it. diff --git a/packages/schemas/README.md b/packages/schemas/README.md index 87c39d4..a70305c 100644 --- a/packages/schemas/README.md +++ b/packages/schemas/README.md @@ -18,6 +18,11 @@ Schema families include: - **OpenRental draft** — the `openrental` export describes listings of OpenAgent profiles and OpenSwarm file-key references, with metadata and CoinPay rental offers. `@logicsrc/validators` also checks membership and rental references. +- **OpenErrand** - the `openerrand` and `openerrand-index` exports describe an + errand a runner performs on a website with no API, and a publisher's list of + them. Fixtures are in `fixtures/openerrand`. `@logicsrc/validators` also checks + references, templates, and that no hand-off card names a personal or secret + input. See `docs/openerrand.md`. ## Install diff --git a/packages/schemas/fixtures/openerrand/ftb-register-business.json b/packages/schemas/fixtures/openerrand/ftb-register-business.json new file mode 100644 index 0000000..4f1d29c --- /dev/null +++ b/packages/schemas/fixtures/openerrand/ftb-register-business.json @@ -0,0 +1,236 @@ +{ + "type": "logicsrc.openerrand", + "version": "0.1", + "id": "https://logicsrc.com/examples/openerrand/ftb-register-business.json", + "name": "ftb-register-business", + "title": "Register a MyFTB business account", + "description": "Creates a MyFTB account for a California S corporation at the Franchise Tax Board, proving the business with a figure from a filed Form 100S. FTB then mails a PIN; activation is a second errand.", + "publisher": "https://logicsrc.com/.well-known/openprofile.md", + "updated": "2026-10-04T00:00:00Z", + "reference": "https://github.com/profullstack/cli-tools/pull/125", + "principal": "self", + "site": { + "name": "California Franchise Tax Board (MyFTB)", + "sector": "tax", + "origins": ["https://webapp.ftb.ca.gov"], + "start": ["https://webapp.ftb.ca.gov/MyFTBAccess/Registration/NewAccount"] + }, + "limits": { "pages": 15, "page_timeout": "PT30S", "same_page": 2 }, + "inputs": { + "email": { + "label": "Email address FTB writes to", + "type": "email", + "sensitivity": "personal", + "required": true, + "sources": [{ "from": "prompt" }] + }, + "phone": { + "label": "Mobile number FTB texts a verification code to", + "type": "string", + "pattern": "^[0-9]{10}$", + "sensitivity": "personal", + "required": true, + "sources": [{ "from": "prompt" }] + }, + "first_name": { + "label": "Representative's first name", + "type": "string", + "max_length": 11, + "sensitivity": "personal", + "sources": [{ "from": "document", "form": "CA 540", "field": "first name", "pick": "newest" }] + }, + "last_name": { + "label": "Representative's last name", + "type": "string", + "max_length": 13, + "sensitivity": "personal", + "sources": [{ "from": "document", "form": "CA 540", "field": "last name", "pick": "newest" }] + }, + "street": { + "label": "Street address on the newest return", + "type": "string", + "sensitivity": "personal", + "sources": [{ "from": "document", "form": "CA 540", "field": "street address", "pick": "newest" }] + }, + "address_numbers": { + "label": "The numbers in the address on file", + "type": "string", + "sensitivity": "personal", + "sources": [{ "from": "derive", "input": "street", "transform": "digits" }] + }, + "zip": { + "label": "ZIP code on file", + "type": "string", + "sensitivity": "personal", + "sources": [{ "from": "document", "form": "CA 540", "field": "ZIP code", "pick": "newest", "transform": "first:5" }] + }, + "corp_id": { + "label": "California corporation number", + "type": "string", + "sensitivity": "public", + "sources": [{ "from": "document", "form": "CA 100S", "field": "California corporation number", "pick": "newest" }] + }, + "tax_year": { + "label": "Tax year of the return the shared secret comes from", + "type": "integer", + "sensitivity": "public", + "sources": [{ "from": "candidate", "input": "net_income", "part": "year" }] + }, + "net_income": { + "label": "Net income for tax purposes, whole dollars", + "type": "integer", + "sensitivity": "secret", + "role": "shared-secret", + "sources": [ + { "from": "document", "form": "CA 100S", "field": "line 20", "match": "net income for tax purposes", "years": { "back": 5, "current": false }, "transform": "whole" }, + { "from": "document", "form": "CA 100S", "field": "line 15", "match": "net income \\(loss\\) for state purposes", "years": { "back": 5, "current": false }, "transform": "whole" } + ] + }, + "username": { + "label": "MyFTB user name", + "type": "string", + "sensitivity": "personal", + "role": "credential", + "sources": [ + { "from": "vault", "key": "FTB_BUSINESS_USERNAME" }, + { "from": "generate", "length": 15, "classes": ["lower", "digit"] } + ] + }, + "password": { + "label": "MyFTB password", + "type": "string", + "sensitivity": "secret", + "role": "credential", + "sources": [ + { "from": "vault", "key": "FTB_BUSINESS_PASSWORD" }, + { "from": "generate", "length": 24, "classes": ["lower", "upper", "digit", "special"], "special": "!#$*@" } + ] + }, + "security": { + "label": "Three security questions and their answers", + "type": "qa-set", + "count": 3, + "sensitivity": "secret", + "role": "credential", + "sources": [ + { "from": "vault", "key": "FTB_BUSINESS_SECURITY_ANSWERS" }, + { "from": "generate", "length": 10, "classes": ["lower", "digit"] } + ] + } + }, + "rules": [ + { "name": "read terms", "id": "^ReadTerms$", "types": ["checkbox"], "do": { "check": true } }, + { "name": "accept terms", "id": "^AcceptTerms$", "types": ["checkbox"], "do": { "check": true } }, + { "name": "first name", "id": "^FstName$", "do": { "text": "{{first_name}}" } }, + { "name": "middle initial", "id": "^MInitial$", "do": { "skip": true } }, + { "name": "last name", "id": "^LstName$", "do": { "text": "{{last_name}}" } }, + { "name": "suffix", "id": "^Sffx$", "do": { "skip": true } }, + { "name": "user name again", "id": "^ReUserName$", "do": { "text": "{{username}}" } }, + { "name": "user name", "id": "^UserName$", "do": { "text": "{{username}}" } }, + { "name": "email again", "id": "^ReEmail$", "do": { "text": "{{email}}" } }, + { "name": "email", "id": "^Email$", "do": { "text": "{{email}}" } }, + { "name": "password again", "id": "^RePassword$", "do": { "text": "{{password}}" } }, + { "name": "password", "id": "^Password$", "do": { "text": "{{password}}" } }, + { "name": "foreign number", "id": "^Phone_Foreign$", "do": { "skip": true } }, + { "name": "foreign address", "id": "^Address_Foreign$|^Address_No(MailAddress|PostalCode)$", "do": { "skip": true } }, + { "name": "security question", "label": "question", "types": ["select-one"], "do": { "choose": "security" } }, + { "name": "security answer", "label": "question|answer", "types": ["text", "password"], "do": { "answer": "security" } }, + { "name": "role", "label": "individual|business representative", "types": ["radio"], "do": { "check": { "label": "^\\s*business representative" } } }, + { "name": "zip", "label": "zip|postal", "types": ["text", "tel", "number"], "do": { "text": "{{zip}}" } }, + { "name": "address numbers", "label": "numbers in (the |your )?(business )?(mailing )?address", "types": ["text", "tel", "number"], "do": { "text": "{{address_numbers}}" } }, + { "name": "tax year", "label": "year (of|on) the tax return|tax year", "types": ["select-one"], "do": { "select": ["^\\s*{{tax_year}}\\s*$"] } }, + { "name": "tax year", "label": "year (of|on) the tax return|tax year", "types": ["text", "tel", "number"], "do": { "text": "{{tax_year}}" } }, + { "name": "net income", "label": "net income|income \\(loss\\)", "types": ["text", "tel", "number"], "do": { "text": "{{net_income}}" } }, + { "name": "company type", "label": "type of company|company type|entity type", "types": ["select-one"], "do": { "select": ["^\\s*corporation\\s*$", "corporation"] } }, + { "name": "account number", "label": "account number|entity id|corporation (id|number)", "types": ["text", "tel", "number"], "do": { "text": "{{corp_id}}" } }, + { "name": "form type", "label": "form type|type of (tax )?(return|form)", "types": ["select-one"], "do": { "select": ["100\\s*S\\b"] } }, + { "name": "declaration", "label": "perjury|i declare|under penalty", "types": ["checkbox"], "do": { "gate": "declaration" } }, + { "name": "phone", "label": "phone number", "types": ["text", "tel", "number"], "do": { "text": "{{phone}}" } }, + { "name": "send a text", "label": "send me a text|text message", "types": ["radio"], "do": { "check": true } }, + { "name": "verification code", "label": "verification code|security code|one[- ]time|passcode|access code|enter (the )?code", "types": ["text", "tel", "number", "password"], "do": { "gate": "text-code" } } + ], + "steps": [ + { + "id": "bot-check", + "kind": "wait", + "match": { "title": "^Challenge Validation$", "selector": "#sec-cpt-if" }, + "timeout": "PT90S", + "poll": "PT3S", + "say": "FTB's bot check is a proof of work the page's own script solves; the runner waits for it." + }, + { + "id": "form", + "kind": "page", + "unmatched": "stop", + "say": "Every MyFTB registration page: terms, profile, security questions, role, address, shared secret, phone." + }, + { + "id": "declaration", + "kind": "declare", + "statement": "perjury|i declare|under penalty", + "why": "Ticking this box is the representative stating, under penalty of perjury, that what was entered is true. Only that person can make the statement." + }, + { + "id": "text-code", + "kind": "code", + "channel": "sms", + "relay": ["terminal", "file"], + "pattern": "^\\w{4,10}$", + "timeout": "PT15M", + "why": "FTB texts a code to the phone number given. Whoever holds the phone reads it out." + }, + { + "id": "pin-letter", + "kind": "mail", + "what": "MyFTB PIN letter", + "arrives": "5 to 10 business days, to the address FTB has on file", + "expires": "P21D", + "resume": "ftb-activate-business", + "input": "pin", + "handoff": "pin-letter", + "why": "FTB activates a new account with a PIN it sends by US Mail. Nobody but the addressee can read it." + } + ], + "submit": { + "labels": "^(submit|continue|next|log ?in|login|activate|send( code| me a code)?|verify|confirm)$", + "never": "^(back|cancel|end session|previous)$", + "ignore": "#timer, .modal" + }, + "outcomes": [ + { + "name": "rejected", + "kind": "rejected", + "text": "does not match our records|there is a problem|unable to (verify|process) your|account (is|has been) locked" + }, + { + "name": "registered", + "kind": "success", + "text": "registration confirmation|successfully (registered|created)|we will (mail|send) you a (letter|pin)|pin .*(mail|letter)", + "then": "pin-letter" + } + ], + "retry": { "shared_secret": "never", "page_errors": "rejected" }, + "outputs": { + "vault": { + "when": "registered", + "keys": { + "FTB_BUSINESS_USERNAME": "{{username}}", + "FTB_BUSINESS_PASSWORD": "{{password}}", + "FTB_BUSINESS_EMAIL": "{{email}}", + "FTB_BUSINESS_SECURITY_ANSWERS": "{{security}}" + } + } + }, + "handoffs": { + "pin-letter": { + "title": "FTB PIN letter: business MyFTB account", + "open": "https://webapp.ftb.ca.gov/MyFTBAccess/", + "steps": [ + "Watch the mail at the address FTB has on file for the MyFTB PIN letter (5 to 10 business days).", + "Activate before {{expires_on}}: the PIN expires 21 days after registration.", + "Run the command below yourself, with the PIN from the letter." + ], + "command": "ftb activate business --pin " + } + } +} diff --git a/packages/schemas/fixtures/openerrand/index.json b/packages/schemas/fixtures/openerrand/index.json new file mode 100644 index 0000000..5ef7a88 --- /dev/null +++ b/packages/schemas/fixtures/openerrand/index.json @@ -0,0 +1,16 @@ +{ + "type": "logicsrc.openerrand-index", + "version": "0.1", + "publisher": "https://logicsrc.com/.well-known/openprofile.md", + "updated": "2026-10-04T00:00:00Z", + "errands": [ + { + "url": "https://logicsrc.com/examples/openerrand/ftb-register-business.json", + "name": "ftb-register-business", + "site": "https://webapp.ftb.ca.gov", + "title": "Register a MyFTB business account", + "gates": ["declare", "code", "mail"], + "updated": "2026-10-04T00:00:00Z" + } + ] +} diff --git a/packages/schemas/package.json b/packages/schemas/package.json index be4c0f2..93f6215 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -23,6 +23,8 @@ "opencontext", "opencreds", "openrental", + "openerrand", + "browser-automation", "openontology", "password-manager", "standards", @@ -67,6 +69,8 @@ "./opencreds-manifest": "./schemas/logicsrc-opencreds-manifest.schema.json", "./opencreds-vault-meta": "./schemas/logicsrc-opencreds-vault-meta.schema.json", "./openrental": "./schemas/logicsrc-openrental.schema.json", + "./openerrand": "./schemas/logicsrc-openerrand.schema.json", + "./openerrand-index": "./schemas/logicsrc-openerrand-index.schema.json", "./openontology-action": "./schemas/logicsrc-openontology-action.schema.json", "./openontology-approval": "./schemas/logicsrc-openontology-approval.schema.json", "./openontology-changeset": "./schemas/logicsrc-openontology-changeset.schema.json", diff --git a/packages/schemas/schemas/logicsrc-openerrand-index.schema.json b/packages/schemas/schemas/logicsrc-openerrand-index.schema.json new file mode 100644 index 0000000..0c7b13e --- /dev/null +++ b/packages/schemas/schemas/logicsrc-openerrand-index.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.logicsrc.com/logicsrc-openerrand-index.schema.json", + "title": "OpenErrand index", + "description": "The list of errand files a publisher serves at /.well-known/openerrand.json.", + "type": "object", + "additionalProperties": false, + "required": ["type", "version", "errands"], + "properties": { + "type": { "const": "logicsrc.openerrand-index" }, + "version": { "const": "0.1" }, + "publisher": { "type": "string", "format": "uri", "pattern": "^https://" }, + "updated": { "type": "string", "format": "date-time" }, + "errands": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["url", "site", "title"], + "properties": { + "url": { "type": "string", "format": "uri", "pattern": "^https://" }, + "name": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,63}$" }, + "site": { "type": "string", "pattern": "^https://[a-z0-9.-]+(:[0-9]+)?$" }, + "title": { "type": "string", "minLength": 1 }, + "gates": { + "type": "array", + "uniqueItems": true, + "items": { "enum": ["declare", "identity-proofing", "code", "mail", "captcha"] } + }, + "updated": { "type": "string", "format": "date-time" } + } + } + } + } +} diff --git a/packages/schemas/schemas/logicsrc-openerrand.schema.json b/packages/schemas/schemas/logicsrc-openerrand.schema.json new file mode 100644 index 0000000..3be1d2c --- /dev/null +++ b/packages/schemas/schemas/logicsrc-openerrand.schema.json @@ -0,0 +1,434 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.logicsrc.com/logicsrc-openerrand.schema.json", + "title": "OpenErrand", + "description": "A declarative description of one errand a person runs on a website that has no API: the site, the inputs and where each may come from, the field rules, the human gates a runner must never automate, the outcomes, the retry policy, the outputs and the hand-off cards.", + "type": "object", + "additionalProperties": false, + "required": ["type", "version", "name", "title", "site", "steps", "outcomes"], + "properties": { + "type": { "const": "logicsrc.openerrand" }, + "version": { "const": "0.1" }, + "id": { "$ref": "#/$defs/httpsUrl" }, + "name": { "$ref": "#/$defs/slug" }, + "title": { "type": "string", "minLength": 1, "maxLength": 160 }, + "description": { "type": "string" }, + "publisher": { "$ref": "#/$defs/httpsUrl" }, + "updated": { "type": "string", "format": "date-time" }, + "reference": { "$ref": "#/$defs/httpsUrl" }, + "principal": { "enum": ["self", "represented"] }, + "site": { + "type": "object", + "additionalProperties": false, + "required": ["name", "origins", "start"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "sector": { "enum": ["government", "tax", "financial", "healthcare", "identity-provider", "commercial", "other"] }, + "origins": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/origin" } }, + "start": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/httpsUrl" } }, + "terms": { "$ref": "#/$defs/httpsUrl" } + } + }, + "limits": { + "type": "object", + "additionalProperties": false, + "properties": { + "pages": { "type": "integer", "minimum": 1, "maximum": 200 }, + "page_timeout": { "$ref": "#/$defs/duration" }, + "same_page": { "type": "integer", "minimum": 1, "maximum": 10 } + } + }, + "inputs": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/inputName" }, + "additionalProperties": { "$ref": "#/$defs/input" } + }, + "rules": { "type": "array", "items": { "$ref": "#/$defs/rule" } }, + "steps": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/step" } }, + "submit": { + "type": "object", + "additionalProperties": false, + "required": ["labels"], + "properties": { + "labels": { "$ref": "#/$defs/pattern" }, + "never": { "$ref": "#/$defs/pattern" }, + "ignore": { "type": "string", "minLength": 1 } + } + }, + "outcomes": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/outcome" } }, + "retry": { + "type": "object", + "additionalProperties": false, + "properties": { + "shared_secret": { "const": "never" }, + "page_errors": { "enum": ["rejected", "continue"] } + } + }, + "outputs": { + "type": "object", + "additionalProperties": false, + "properties": { + "vault": { + "type": "object", + "additionalProperties": false, + "required": ["keys"], + "properties": { + "when": { "type": "string", "minLength": 1 }, + "keys": { + "type": "object", + "minProperties": 1, + "propertyNames": { "pattern": "^[A-Z][A-Z0-9_]*$" }, + "additionalProperties": { "type": "string", "minLength": 1 } + } + } + }, + "downloads": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["match", "to"], + "properties": { + "match": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "url": { "$ref": "#/$defs/pattern" }, + "filename": { "$ref": "#/$defs/pattern" }, + "type": { "type": "string", "minLength": 1 } + } + }, + "to": { "type": "string", "minLength": 1 }, + "when": { "type": "string", "minLength": 1 } + } + } + } + } + }, + "handoffs": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/slug" }, + "additionalProperties": { "$ref": "#/$defs/handoff" } + }, + "metadata": { "type": "object" } + }, + "$defs": { + "httpsUrl": { "type": "string", "format": "uri", "pattern": "^https://" }, + "origin": { "type": "string", "pattern": "^https://[a-z0-9.-]+(:[0-9]+)?$" }, + "slug": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,63}$" }, + "inputName": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, + "pattern": { "type": "string", "minLength": 1, "format": "regex" }, + "duration": { "type": "string", "format": "duration" }, + "transform": { "type": "string", "pattern": "^(digits|whole|upper|lower|trim|first:[0-9]+|last:[0-9]+)$" }, + "fieldType": { "enum": ["text", "password", "email", "tel", "number", "date", "textarea", "select-one", "radio", "checkbox"] }, + "input": { + "type": "object", + "additionalProperties": false, + "required": ["type", "sensitivity", "sources"], + "properties": { + "label": { "type": "string", "minLength": 1 }, + "type": { "enum": ["string", "integer", "number", "email", "date", "boolean", "qa-set"] }, + "sensitivity": { "enum": ["public", "personal", "secret"] }, + "role": { "enum": ["shared-secret", "credential", "identifier"] }, + "required": { "type": "boolean" }, + "pattern": { "$ref": "#/$defs/pattern" }, + "max_length": { "type": "integer", "minimum": 1 }, + "count": { "type": "integer", "minimum": 1, "maximum": 10 }, + "sources": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/source" } } + } + }, + "source": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["from"], + "properties": { "from": { "const": "prompt" }, "ask": { "type": "string", "minLength": 1 } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["from", "key"], + "properties": { "from": { "const": "vault" }, "key": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["from", "form", "field"], + "properties": { + "from": { "const": "document" }, + "form": { "type": "string", "minLength": 1 }, + "field": { "type": "string", "minLength": 1 }, + "match": { "$ref": "#/$defs/pattern" }, + "pick": { "enum": ["newest", "oldest", "each"] }, + "years": { + "type": "object", + "additionalProperties": false, + "properties": { + "back": { "type": "integer", "minimum": 1, "maximum": 50 }, + "current": { "type": "boolean" } + } + }, + "transform": { "$ref": "#/$defs/transform" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["from", "input", "transform"], + "properties": { + "from": { "const": "derive" }, + "input": { "$ref": "#/$defs/inputName" }, + "transform": { "$ref": "#/$defs/transform" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["from", "input", "part"], + "properties": { + "from": { "const": "candidate" }, + "input": { "$ref": "#/$defs/inputName" }, + "part": { "enum": ["year", "form", "field", "source"] } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["from", "length", "classes"], + "properties": { + "from": { "const": "generate" }, + "length": { "type": "integer", "minimum": 1, "maximum": 256 }, + "classes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "enum": ["lower", "upper", "digit", "special"] } + }, + "special": { "type": "string", "minLength": 1 } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["from", "value"], + "properties": { "from": { "const": "literal" }, "value": { "anyOf": [{ "type": "string" }, { "type": "number" }, { "type": "boolean" }] } } + } + ] + }, + "rule": { + "type": "object", + "additionalProperties": false, + "required": ["name", "do"], + "anyOf": [{ "properties": { "id": true }, "required": ["id"] }, { "properties": { "label": true }, "required": ["label"] }], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "id": { "$ref": "#/$defs/pattern" }, + "label": { "$ref": "#/$defs/pattern" }, + "types": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/fieldType" } }, + "do": { "$ref": "#/$defs/action" } + } + }, + "action": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["text"], + "properties": { + "text": { "type": "string" }, + "split": { "type": "array", "minItems": 2, "items": { "type": "integer", "minimum": 1 } } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["select"], + "properties": { "select": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/pattern" } } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["check"], + "properties": { + "check": { + "oneOf": [ + { "const": true }, + { + "type": "object", + "additionalProperties": false, + "required": ["label"], + "properties": { "label": { "$ref": "#/$defs/pattern" } } + } + ] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["skip"], + "properties": { "skip": { "const": true } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["gate"], + "properties": { "gate": { "$ref": "#/$defs/slug" } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["choose"], + "properties": { "choose": { "$ref": "#/$defs/inputName" } } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["answer"], + "properties": { "answer": { "$ref": "#/$defs/inputName" } } + } + ] + }, + "match": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "url": { "$ref": "#/$defs/pattern" }, + "title": { "$ref": "#/$defs/pattern" }, + "text": { "$ref": "#/$defs/pattern" }, + "selector": { "type": "string", "minLength": 1 } + } + }, + "step": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind"], + "properties": { + "id": { "$ref": "#/$defs/slug" }, + "kind": { "const": "page" }, + "match": { "$ref": "#/$defs/match" }, + "rules": { "type": "array", "items": { "$ref": "#/$defs/rule" } }, + "unmatched": { "enum": ["stop", "ask"] }, + "say": { "type": "string" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "match", "timeout"], + "properties": { + "id": { "$ref": "#/$defs/slug" }, + "kind": { "const": "wait" }, + "match": { "$ref": "#/$defs/match" }, + "timeout": { "$ref": "#/$defs/duration" }, + "poll": { "$ref": "#/$defs/duration" }, + "say": { "type": "string" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "statement", "why"], + "properties": { + "id": { "$ref": "#/$defs/slug" }, + "kind": { "const": "declare" }, + "statement": { "$ref": "#/$defs/pattern" }, + "why": { "type": "string", "minLength": 1 }, + "say": { "type": "string" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "provider", "origins", "why"], + "properties": { + "id": { "$ref": "#/$defs/slug" }, + "kind": { "const": "identity-proofing" }, + "provider": { "type": "string", "minLength": 1 }, + "origins": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/origin" } }, + "match": { "$ref": "#/$defs/match" }, + "timeout": { "$ref": "#/$defs/duration" }, + "handoff": { "$ref": "#/$defs/slug" }, + "why": { "type": "string", "minLength": 1 }, + "say": { "type": "string" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "channel", "relay", "timeout"], + "properties": { + "id": { "$ref": "#/$defs/slug" }, + "kind": { "const": "code" }, + "channel": { "enum": ["sms", "email", "voice", "app"] }, + "relay": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["terminal", "file", "page"] } }, + "pattern": { "$ref": "#/$defs/pattern" }, + "timeout": { "$ref": "#/$defs/duration" }, + "why": { "type": "string" }, + "say": { "type": "string" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "what"], + "properties": { + "id": { "$ref": "#/$defs/slug" }, + "kind": { "const": "mail" }, + "what": { "type": "string", "minLength": 1 }, + "arrives": { "type": "string" }, + "expires": { "$ref": "#/$defs/duration" }, + "resume": { "$ref": "#/$defs/slug" }, + "input": { "$ref": "#/$defs/inputName" }, + "handoff": { "$ref": "#/$defs/slug" }, + "why": { "type": "string" }, + "say": { "type": "string" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "match"], + "properties": { + "id": { "$ref": "#/$defs/slug" }, + "kind": { "const": "captcha" }, + "match": { "$ref": "#/$defs/match" }, + "solver": { "enum": ["forbidden", "allowed"] }, + "timeout": { "$ref": "#/$defs/duration" }, + "why": { "type": "string" }, + "say": { "type": "string" } + } + } + ] + }, + "outcome": { + "type": "object", + "additionalProperties": false, + "required": ["name", "kind"], + "anyOf": [{ "properties": { "text": true }, "required": ["text"] }, { "properties": { "url": true }, "required": ["url"] }], + "properties": { + "name": { "$ref": "#/$defs/slug" }, + "kind": { "enum": ["success", "rejected", "waiting"] }, + "text": { "$ref": "#/$defs/pattern" }, + "url": { "$ref": "#/$defs/pattern" }, + "then": { "$ref": "#/$defs/slug" } + } + }, + "handoff": { + "type": "object", + "additionalProperties": false, + "required": ["title", "steps"], + "properties": { + "title": { "type": "string", "minLength": 1 }, + "open": { "$ref": "#/$defs/httpsUrl" }, + "steps": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, + "command": { "type": "string" } + } + } + } +} diff --git a/packages/validators/package.json b/packages/validators/package.json index e94cea7..0d46a6b 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -11,7 +11,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "test": "vitest run src", - "validate:fixtures": "node dist/cli.js task ../schemas/fixtures/task.yaml && node dist/cli.js agent ../schemas/fixtures/agent.yaml && node dist/cli.js agentad-ad ../schemas/fixtures/agentad-ad.yaml && node dist/cli.js agentad-placement ../schemas/fixtures/agentad-placement.yaml && node dist/cli.js repo ../schemas/fixtures/repo.yaml && node dist/cli.js pull-request ../schemas/fixtures/pull-request.yaml && node dist/cli.js openontology-manifest ../schemas/fixtures/openontology/valid/manifest.json && node dist/cli.js openontology-claim ../schemas/fixtures/openontology/valid/claim-relationship.json && node dist/cli.js openontology-changeset ../schemas/fixtures/openontology/valid/changeset.json && node dist/cli.js opencontext-manifest ../schemas/fixtures/opencontext/valid/manifest.json && node dist/cli.js opencontext-object ../schemas/fixtures/opencontext/valid/object-policy.json && node dist/cli.js opencontext-bundle ../schemas/fixtures/opencontext/valid/bundle.json && node dist/cli.js opencontext-decision ../schemas/fixtures/opencontext/valid/decision.json && node dist/cli.js opencontext-role ../schemas/fixtures/opencontext/valid/role.json && node dist/cli.js opencontext-provenance ../schemas/fixtures/opencontext/valid/provenance.json && node dist/cli.js opencontext-diagnostic ../schemas/fixtures/opencontext/valid/diagnostic.json && node dist/cli.js opencontext-audit-event ../schemas/fixtures/opencontext/valid/audit-event.json && node dist/cli.js openrental ../schemas/fixtures/openrental/mixed.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/broadcast.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/direct.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/announcement.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-accepted.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-retrying.json && node dist/cli.js openabtest-manifest ../schemas/fixtures/openabtest/chovy-manifest.json && node dist/cli.js openabtest-event ../schemas/fixtures/openabtest/reconciliation.json" + "validate:fixtures": "node dist/cli.js task ../schemas/fixtures/task.yaml && node dist/cli.js agent ../schemas/fixtures/agent.yaml && node dist/cli.js agentad-ad ../schemas/fixtures/agentad-ad.yaml && node dist/cli.js agentad-placement ../schemas/fixtures/agentad-placement.yaml && node dist/cli.js repo ../schemas/fixtures/repo.yaml && node dist/cli.js pull-request ../schemas/fixtures/pull-request.yaml && node dist/cli.js openontology-manifest ../schemas/fixtures/openontology/valid/manifest.json && node dist/cli.js openontology-claim ../schemas/fixtures/openontology/valid/claim-relationship.json && node dist/cli.js openontology-changeset ../schemas/fixtures/openontology/valid/changeset.json && node dist/cli.js opencontext-manifest ../schemas/fixtures/opencontext/valid/manifest.json && node dist/cli.js opencontext-object ../schemas/fixtures/opencontext/valid/object-policy.json && node dist/cli.js opencontext-bundle ../schemas/fixtures/opencontext/valid/bundle.json && node dist/cli.js opencontext-decision ../schemas/fixtures/opencontext/valid/decision.json && node dist/cli.js opencontext-role ../schemas/fixtures/opencontext/valid/role.json && node dist/cli.js opencontext-provenance ../schemas/fixtures/opencontext/valid/provenance.json && node dist/cli.js opencontext-diagnostic ../schemas/fixtures/opencontext/valid/diagnostic.json && node dist/cli.js opencontext-audit-event ../schemas/fixtures/opencontext/valid/audit-event.json && node dist/cli.js openrental ../schemas/fixtures/openrental/mixed.json && node dist/cli.js openerrand ../schemas/fixtures/openerrand/ftb-register-business.json && node dist/cli.js openerrand-index ../schemas/fixtures/openerrand/index.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/broadcast.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/direct.json && node dist/cli.js openwall-message ../schemas/fixtures/openwall/announcement.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-accepted.json && node dist/cli.js openwall-receipt ../schemas/fixtures/openwall/receipt-retrying.json && node dist/cli.js openabtest-manifest ../schemas/fixtures/openabtest/chovy-manifest.json && node dist/cli.js openabtest-event ../schemas/fixtures/openabtest/reconciliation.json" }, "dependencies": { "@logicsrc/schemas": "^0.3.0", diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index d61ebc7..eb895d7 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -5,6 +5,7 @@ import { parse } from "yaml"; import { isSchemaKind, schemas, type SchemaKind } from "./schemas.js"; import { validateOpenABTest } from "./openabtest.js"; import { validateOpenRentalReferences } from "./openrental.js"; +import { validateOpenErrandReferences } from "./openerrand.js"; type CompiledSchema = { (data: unknown): boolean; errors?: ErrorObject[] | null }; @@ -67,6 +68,10 @@ export function validate(kind: SchemaKind, data: unknown): ValidationResult { const errors = validateOpenRentalReferences(data); if (errors.length) return { ok: false, kind, errors }; } + if (kind === "openerrand") { + const errors = validateOpenErrandReferences(data); + if (errors.length) return { ok: false, kind, errors }; + } if (kind === "openabtest-manifest" || kind === "openabtest-event") { const errors = validateOpenABTest(kind, data); if (errors.length) return { ok: false, kind, errors }; diff --git a/packages/validators/src/openerrand.test.ts b/packages/validators/src/openerrand.test.ts new file mode 100644 index 0000000..1595796 --- /dev/null +++ b/packages/validators/src/openerrand.test.ts @@ -0,0 +1,102 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { assertSchemaKind, validate } from "./index.js"; + +const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); +const fixture = () => JSON.parse(read("../../schemas/fixtures/openerrand/ftb-register-business.json")); +const doc = read("../../../docs/openerrand.md"); + +/** Every ```json block in docs/openerrand.md, in order. */ +const blocks = [...doc.matchAll(/```json\n([\s\S]*?)\n```/g)].map((m) => m[1]!); + +function errorAt(data: unknown, keyword: string, path: string) { + const result = validate("openerrand", data); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.errors).toContainEqual(expect.objectContaining({ keyword, instancePath: path })); +} + +describe("OpenErrand", () => { + it("registers both exported schemas and validates the fixtures", () => { + expect(assertSchemaKind("openerrand")).toBe("openerrand"); + expect(assertSchemaKind("openerrand-index")).toBe("openerrand-index"); + expect(validate("openerrand", fixture())).toMatchObject({ ok: true }); + expect(validate("openerrand-index", JSON.parse(read("../../schemas/fixtures/openerrand/index.json")))).toMatchObject({ ok: true }); + }); + + it("validates the smallest errand, the worked example and the index printed in the specification", () => { + const [smallest, , , index, example] = blocks.map((b) => JSON.parse(b)); + expect(validate("openerrand", smallest)).toMatchObject({ ok: true }); + expect(example).toEqual(fixture()); + expect(validate("openerrand-index", index)).toMatchObject({ ok: true }); + }); + + it("puts no personal or secret value in the published example", () => { + // The repository is public: the example names fields and sources, never a person's data. + const text = JSON.stringify(fixture()); + expect(text).not.toMatch(/\b\d{3}-?\d{2}-?\d{4}\b/); + expect(text).not.toMatch(/Jane|Doe|Maple|1234567|48210/); + }); + + it.each([ + ["an unknown key", (f: any) => { f.solver = "2captcha"; }, "additionalProperties", ""], + ["an input with no sensitivity", (f: any) => { delete f.inputs.email.sensitivity; }, "required", "/inputs/email"], + ["a shared secret that may be retried", (f: any) => { f.retry.shared_secret = "once"; }, "const", "/retry/shared_secret"], + ["a gate step without its why", (f: any) => { delete f.steps[2].why; }, "oneOf", "/steps/2"], + ["a plain http origin", (f: any) => { f.site.origins = ["http://webapp.ftb.ca.gov"]; }, "pattern", "/site/origins/0"] + ])("rejects %s", (_, mutate, keyword, path) => { + const f = fixture(); + mutate(f); + errorAt(f, keyword, path); + }); + + it.each([ + ["a personal input on a hand-off card", (f: any) => { f.handoffs["pin-letter"].steps.push("Mail it to {{street}}"); }, "handoffSensitivity", "/handoffs/pin-letter/steps/3"], + ["a secret input in a card's command", (f: any) => { f.handoffs["pin-letter"].command = "ftb activate --password {{password}}"; }, "handoffSensitivity", "/handoffs/pin-letter/command"], + ["a template naming no input", (f: any) => { f.rules[2].do.text = "{{given_name}}"; }, "templateReference", "/rules/2/do/text"], + ["a gate action pointing at a page step", (f: any) => { f.rules[25].do.gate = "form"; }, "gateReference", "/rules/25/do/gate"], + ["a shared secret that is only personal", (f: any) => { f.inputs.net_income.sensitivity = "personal"; }, "sharedSecretSensitivity", "/inputs/net_income/sensitivity"], + ["a choose action on a plain input", (f: any) => { f.rules[14].do.choose = "email"; }, "qaSetReference", "/rules/14/do/choose"], + ["an outcome that follows a missing step", (f: any) => { f.outcomes[1].then = "pin-postcard"; }, "stepReference", "/outcomes/1/then"], + ["a mail gate with a missing card", (f: any) => { f.steps[4].handoff = "postcard"; }, "handoffReference", "/steps/4/handoff"], + ["duplicate step ids", (f: any) => { f.steps[1].id = "bot-check"; }, "uniqueStep", "/steps/1/id"] + ])("rejects %s", (_, mutate, keyword, path) => { + const f = fixture(); + mutate(f); + errorAt(f, keyword, path); + }); + + const commercial = () => ({ + ...JSON.parse(blocks[0]!), + site: { name: "Example", sector: "commercial", origins: ["https://example.com"], start: ["https://example.com/contact"] }, + steps: [{ id: "form", kind: "page" }, { id: "robot-check", kind: "captcha", match: { selector: "iframe[src*=captcha]" }, solver: "allowed" }] + }); + + it("allows a declared captcha solver on a commercial errand with nothing sensitive", () => { + expect(validate("openerrand", commercial())).toMatchObject({ ok: true }); + }); + + it.each([ + ["a solver on a tax site", (f: any) => { f.steps.push({ id: "robot-check", kind: "captcha", match: { selector: "iframe" }, solver: "allowed" }); }, "/steps/5/solver"], + ["a solver on an errand that does not state its sector", (f: any) => { f.steps.push({ id: "robot-check", kind: "captcha", match: { selector: "iframe" }, solver: "allowed" }); delete f.site.sector; }, "/steps/5/solver"] + ])("rejects %s", (_, mutate, path) => { + const f = fixture(); + mutate(f); + errorAt(f, "captchaSolver", path); + }); + + it.each([ + ["a secret input", (f: any) => { f.inputs = { password: { type: "string", sensitivity: "secret", sources: [{ from: "prompt" }] } }; }], + ["a declare step", (f: any) => { f.steps.push({ id: "attest", kind: "declare", statement: "i declare", why: "Yours to say." }); }], + ["a government site", (f: any) => { f.site.sector = "government"; }] + ])("rejects a captcha solver on a commercial errand with %s", (_, mutate) => { + const f = commercial(); + mutate(f); + errorAt(f, "captchaSolver", "/steps/1/solver"); + }); + + it("allows public inputs and built-ins on a card", () => { + const f = fixture(); + f.handoffs["pin-letter"].steps.push("Corporation {{corp_id}} at {{site.name}}: {{errand.title}}"); + expect(validate("openerrand", f)).toMatchObject({ ok: true }); + }); +}); diff --git a/packages/validators/src/openerrand.ts b/packages/validators/src/openerrand.ts new file mode 100644 index 0000000..eeeb9fc --- /dev/null +++ b/packages/validators/src/openerrand.ts @@ -0,0 +1,159 @@ +import type { ErrorObject } from "ajv"; + +// Called only after the JSON Schema has checked the shape. These are the +// OpenErrand rules that need sibling values: every reference resolves, every +// template names an input that exists, and nothing personal or secret can be +// rendered onto a hand-off card. + +type Source = { from: string; input?: string }; +type Input = { type: string; sensitivity: "public" | "personal" | "secret"; role?: string; sources: Source[] }; +type Action = { text?: string; gate?: string; choose?: string; answer?: string }; +type Rule = { do: Action }; +type Step = { id: string; kind: string; rules?: Rule[]; handoff?: string; resume?: string; solver?: string }; +type Errand = { + site: { sector?: string }; + inputs?: Record; + rules?: Rule[]; + steps: Step[]; + outcomes: Array<{ name: string; then?: string }>; + outputs?: { vault?: { when?: string; keys: Record }; downloads?: Array<{ to: string; when?: string }> }; + handoffs?: Record; +}; + +/** Step kinds a runner must hand to a person. */ +export const GATE_KINDS = ["declare", "identity-proofing", "code", "mail", "captcha"] as const; + +/** Sectors where a captcha solver is never allowed. */ +export const NO_SOLVER_SECTORS = ["government", "tax", "financial", "healthcare", "identity-provider"] as const; + +/** Names a hand-off card may use: none of them is a person's data. */ +export const HANDOFF_BUILTINS = ["expires_on", "errand.title", "site.name"] as const; + +const TEMPLATE = /\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}/g; + +export function templateNames(text: string): string[] { + return [...text.matchAll(TEMPLATE)].map((m) => m[1]!); +} + +export function validateOpenErrandReferences(data: unknown): ErrorObject[] { + const errand = data as Errand; + const errors: ErrorObject[] = []; + function report(keyword: string, instancePath: string, message: string) { + errors.push({ keyword, instancePath, schemaPath: "#/openerrand-semantics", params: {}, message }); + } + + const inputs = errand.inputs ?? {}; + const steps = new Map(); + errand.steps.forEach((step, i) => { + if (steps.has(step.id)) report("uniqueStep", `/steps/${i}/id`, "duplicates an existing step id"); + steps.set(step.id, step); + }); + const outcomes = new Set(); + errand.outcomes.forEach((outcome, i) => { + if (outcomes.has(outcome.name)) report("uniqueOutcome", `/outcomes/${i}/name`, "duplicates an existing outcome name"); + outcomes.add(outcome.name); + }); + const handoffs = errand.handoffs ?? {}; + + for (const [name, input] of Object.entries(inputs)) { + const path = `/inputs/${name}`; + if (input.role === "shared-secret" && input.sensitivity !== "secret") { + report("sharedSecretSensitivity", `${path}/sensitivity`, "a shared secret is always sensitivity secret"); + } + if (input.type === "qa-set" && input.sensitivity === "public") { + report("qaSetSensitivity", `${path}/sensitivity`, "security answers are never public"); + } + input.sources.forEach((source, j) => { + if ((source.from === "derive" || source.from === "candidate") && (!source.input || !(source.input in inputs) || source.input === name)) { + report("inputReference", `${path}/sources/${j}/input`, "must name another input of this errand"); + } + if (source.from === "candidate" && source.input && inputs[source.input]?.role !== "shared-secret") { + report("candidateReference", `${path}/sources/${j}/input`, "a candidate part comes from a shared-secret input"); + } + }); + } + + function checkTemplate(text: string, path: string) { + for (const name of templateNames(text)) { + if (!(name in inputs)) report("templateReference", path, `{{${name}}} is not an input of this errand`); + } + } + + function checkRules(rules: Rule[] | undefined, base: string) { + rules?.forEach((rule, i) => { + const path = `${base}/${i}/do`; + const action = rule.do; + if (action.text !== undefined) checkTemplate(action.text, `${path}/text`); + if (action.gate !== undefined) { + const target = steps.get(action.gate); + if (!target || !(GATE_KINDS as readonly string[]).includes(target.kind)) { + report("gateReference", `${path}/gate`, "must name a declare, identity-proofing, code, mail or captcha step"); + } + } + for (const key of ["choose", "answer"] as const) { + const name = action[key]; + if (name !== undefined && inputs[name]?.type !== "qa-set") { + report("qaSetReference", `${path}/${key}`, "must name a qa-set input"); + } + } + }); + } + checkRules(errand.rules, "/rules"); + errand.steps.forEach((step, i) => checkRules(step.rules, `/steps/${i}/rules`)); + + errand.steps.forEach((step, i) => { + if (step.handoff !== undefined && !(step.handoff in handoffs)) { + report("handoffReference", `/steps/${i}/handoff`, "must name a hand-off card of this errand"); + } + }); + errand.outcomes.forEach((outcome, i) => { + if (outcome.then !== undefined && !steps.has(outcome.then)) { + report("stepReference", `/outcomes/${i}/then`, "must name a step of this errand"); + } + }); + + const vault = errand.outputs?.vault; + if (vault) { + if (vault.when !== undefined && !outcomes.has(vault.when)) report("outcomeReference", "/outputs/vault/when", "must name an outcome"); + for (const [key, value] of Object.entries(vault.keys)) checkTemplate(value, `/outputs/vault/keys/${key}`); + } + errand.outputs?.downloads?.forEach((download, i) => { + if (download.when !== undefined && !outcomes.has(download.when)) report("outcomeReference", `/outputs/downloads/${i}/when`, "must name an outcome"); + checkTemplate(download.to, `/outputs/downloads/${i}/to`); + }); + + // A captcha solver is allowed only where nothing sensitive is at stake: a + // stated sector outside the forbidden set, no attestation, no identity + // proofing and no secret input. + const sensitive = + errand.steps.some((step) => step.kind === "declare" || step.kind === "identity-proofing") || + Object.values(inputs).some((input) => input.sensitivity === "secret"); + const sector = errand.site?.sector; + errand.steps.forEach((step, i) => { + if (step.kind !== "captcha" || step.solver !== "allowed") return; + const path = `/steps/${i}/solver`; + if (!sector) report("captchaSolver", path, "an errand that allows a captcha solver states its site.sector"); + else if ((NO_SOLVER_SECTORS as readonly string[]).includes(sector)) report("captchaSolver", path, `a captcha solver is never allowed on a ${sector} site`); + if (sensitive) report("captchaSolver", path, "a captcha solver is never allowed on an errand with a declare or identity-proofing step or a secret input"); + }); + + // A hand-off card is shared on purpose: it may name built-ins and public + // inputs, and nothing a person would not post on a fridge. + for (const [id, card] of Object.entries(handoffs)) { + const texts: Array<[string, string]> = [ + [`/handoffs/${id}/title`, card.title], + ...card.steps.map((step, i): [string, string] => [`/handoffs/${id}/steps/${i}`, step]), + ...(card.command !== undefined ? [[`/handoffs/${id}/command`, card.command] as [string, string]] : []) + ]; + for (const [path, text] of texts) { + for (const name of templateNames(text)) { + if ((HANDOFF_BUILTINS as readonly string[]).includes(name)) continue; + const input = inputs[name]; + if (!input) report("templateReference", path, `{{${name}}} is not an input or a hand-off built-in`); + else if (input.sensitivity !== "public") report("handoffSensitivity", path, `{{${name}}} is ${input.sensitivity} and never goes on a hand-off card`); + } + } + } + + return errors; +} diff --git a/packages/validators/src/schemas.ts b/packages/validators/src/schemas.ts index c230147..6d54881 100644 --- a/packages/validators/src/schemas.ts +++ b/packages/validators/src/schemas.ts @@ -11,6 +11,8 @@ import openabtestEventSchema from "@logicsrc/schemas/openabtest-event" with { ty */ import agentSchema from "@logicsrc/schemas/agent" with { type: "json" }; import openrentalSchema from "@logicsrc/schemas/openrental" with { type: "json" }; +import openerrandSchema from "@logicsrc/schemas/openerrand" with { type: "json" }; +import openerrandIndexSchema from "@logicsrc/schemas/openerrand-index" with { type: "json" }; import accountAuditEventSchema from "@logicsrc/schemas/account-audit-event" with { type: "json" }; import accountGrantSchema from "@logicsrc/schemas/account-grant" with { type: "json" }; import accountProviderSchema from "@logicsrc/schemas/account-provider" with { type: "json" }; @@ -81,6 +83,8 @@ export const schemas = { "openwall-receipt": openwallReceiptSchema, agent: agentSchema, openrental: openrentalSchema, + openerrand: openerrandSchema, + "openerrand-index": openerrandIndexSchema, "account-audit-event": accountAuditEventSchema, "account-grant": accountGrantSchema, "account-provider": accountProviderSchema,