OpenErrand 0.1: an errand on a website with no API, with the human steps kept human (#227)

* OpenErrand 0.1: an errand on a website with no API, with the human steps kept human

docs/openerrand.md mints OpenErrand: one JSON file per errand (register an
account, download a transcript) naming the site, the inputs with a
sensitivity class and ordered sources (document, vault, prompt, generate,
derive, candidate, literal), field rules matched by id then label, page and
wait steps, five human gates a runner never performs (declare,
identity-proofing, code, mail, captcha), outcomes, the never-retried shared
secret, vault and download outputs, hand-off cards that may name only public
inputs, the publisher index at /.well-known/openerrand.json, and thirteen
runner rules. The worked example is the MyFTB business registration that
cli-tools `ftb` performs (profullstack/cli-tools#125), with no personal data.

- @logicsrc/schemas: openerrand + openerrand-index schemas and fixtures
- @logicsrc/validators: semantic checks (references, templates, no personal
  or secret input on a card) and tests that validate the spec's own examples
- logicsrc-web: registry entry (process family), /openerrand landing page,
  the example and the index served as static files, contract tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* OpenErrand: hand-off cards stay on the surface that owns the data

Anthony's ruling: tax and finance data never touches a social or promotion
tool, and nothing is sent to a CPA or preparer.

- Hand-off cards are delivered only on the surface that owns the errand's
  data (for a tax or finance errand, the principal's finance app through its
  CLI, PWA, MCP server or API, such as CoinPay, or the runner's terminal),
  never a social, promotion or third-party posting service, and never to
  anyone but the principal. A card for an errand with personal or secret
  inputs does not leave that surface. Runner rule 9 says the same.
- The run record and the sample run name the card by an opaque id
  (pin-letter/7f3k2q) instead of a mynaposter.com URL; the myna mention is gone.
- `principal: represented` no longer cites a preparer with a power of attorney.
- The FTB card's last step no longer suggests sending the PIN to someone else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* OpenErrand: user-agent rule, captcha solver policy, reference runner note

Anthony's answers on #227 ("go with your recommendations"):

- Rule 11: a runner may run headless with a normal desktop browser user agent
  (dropping HeadlessChrome) and nothing more: no fingerprint spoofing beyond
  the UA string, no stealth plugins, no solving or evading a bot challenge.
  A challenge the browser completes itself is a wait step; any other is a
  captcha gate.
- Captcha solvers: new site.sector and captcha step `solver`
  (forbidden by default | allowed). Never allowed on government, tax,
  financial, healthcare or identity-provider sites, nor on any errand with a
  declare or identity-proofing step or a secret input; elsewhere only when the
  file says so, with every use logged. The validator rejects `allowed` in the
  forbidden set or without a stated sector; six new tests. The FTB example
  states sector "tax".
- Reference runner: @logicsrc/openerrand / `logicsrc errand run`, marked in
  progress; ftb stays the runner the example was taken from.
- Name stays OpenErrand; family stays Agents and process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-10-04 08:57:10 -07:00 • committed by GitHub
parent d38db62e8b
commit 1d69dc3804
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2231 additions and 3 deletions

View file

@ -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",

View file

@ -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 };

View file

@ -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 });
});
});

View file

@ -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<string, Input>;
rules?: Rule[];
steps: Step[];
outcomes: Array<{ name: string; then?: string }>;
outputs?: { vault?: { when?: string; keys: Record<string, string> }; downloads?: Array<{ to: string; when?: string }> };
handoffs?: Record<string, { title: string; steps: string[]; command?: string }>;
};
/** 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<string, Step>();
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<string>();
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;
}

View file

@ -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,