Charter design playbook
How to draft strong Charters that work for human, agent, and hybrid seats.
A Charter is a seat's operating contract. It must be specific enough for a human to understand the job and strict enough for an agent to operate safely.
Required Charter sections
- Purpose: why this seat exists.
- Responsibilities: the recurring outcomes the seat owns.
- Autonomous scope: what the seat may do without asking.
- Approval scope: what requires human confirmation.
- Must-escalate conditions: what the seat must not decide alone.
- Measurables: how the seat knows it is on track.
- Tools and data: which systems the seat may use.
- Reporting rhythm: what the seat reports into Signal, Friction, Cascade, and Sync.
Responsibility quality bar
Responsibilities should be outcomes, not vague activities. "Keep vendor payments clean and on time" is stronger than "help with accounting." The test is whether a measurable, escalation, or handoff can be attached to the responsibility.
Agent and hybrid Charters
Agent Charters need tighter boundaries than human Charters. They must separate autonomous work from approval-required work, and they must name conditions that always escalate.
Hybrid Charters should split the work: what the human remains accountable for, what the agent performs, and where the handoff happens.
Operate Charters from CLI or MCP
List and read first, then draft, then leave activation to a human.
- List:
rost command charter.list --json '{}'orrost_list_charters(scope: tenant). Read one withcharter.get/rost_get_charter. - Draft one:
rost command charter.draft --json '{"seat_id":"<seat-id>"}'(scope: seat or tenant-admin); draft every eligible seat withcharter.draft_all/rost_draft_all_charters. Draft commands are not gated — they produce a draftcharter_version_id.charter.draft_alldrafts every active or vacant non-reserved seat — a vacant seat can receive a conservative draft Charter before it is staffed, since Charters attach to seats, not people, and drafting is not activation: Charter activation stays a separate, human-gated step (charter.approve) whether or not the seat is staffed. Reserved system paths (e.g.system.ops, Forge role seats) are excluded entirely, before both drafting and skip accounting — they never appear indrafted,skipped, orskipped_seats. It returns the real result:{ drafted, skipped, draft_ids, skipped_seats: [{ seat_id, reason }], effect }wherereasonisseat_not_active(the seat is still a graph-authoring draft, not yet active or vacant) orcharter_exists(it already has a draft/active Charter). Readskipped_seatsto see why a freshly-built graph drafted fewer Charters than it has seats — among non-reserved seats. - Never read a governed command's success as "it changed something." Every converted mutating command carries an
effectblock —{ attempted, applied, skipped, outcome, skips: [{ target_kind, target_id, reason }], skips_omitted }, whereoutcomeisapplied,partial, ornone, andskips.length + skips_omittedalways equalsskipped(skipsis capped at 50 entries, so a very large sweep itemises the first 50 and counts the rest — the arithmetic still adds up) — so you can branch on it without knowing any one command's field names. Read it where your surface actually delivers it:response.output.effectover HTTP, andeffecton the unwrapped command output in CLI and MCP. (The executor also carries a copy on its internal result envelope, but the HTTP adapter does not re-emit it, soresponse.effectisundefined— do not read it there.)outcome: "none"means the call succeeded and changed nothing;skipstells you why, per target, as stable codes you can branch on rather than prose. Check it and self-correct in the same run instead of reporting a build that did nothing: a secondcharter.draft_allover a graph whose seats already have a draft or active Charter returnsoutcome: "none"with onecharter_existsskip per seat, and a graph whose seats are still graph-authoringdraftseats returnsoutcome: "none"with oneseat_not_activeskip per seat. (A freshly built graph of vacant seats is not one of these cases — vacant seats are eligible and do get drafted, per the bullet above.) A converted command that skipped work without naming a reason fails its own output validation, so a silent zero-effect success is not a shape *those* commands can return. This is not yet an API-wide guarantee — most mutating commands are not converted yet, and an unconverted bulk or sweep command still returns a bare success that changed nothing:confirmation.dismiss_stalewith no expired confirmations returns{ dismissed_count: 0, dismissed_ids: [] }with noeffectat all. Treat a missingeffectas "this command cannot tell you", never as "it worked" — for those, verify against live state before reporting the step done. Input that violates a schema is reported the same way rather than swallowed, naming the offending field path, the stable issuecode, and the violatedlimit— e.g. a 6-principle Compass againstmax(5)gives{ path: "doc.principles", code: "too_big", limit: 5, expected: "<= 5", received: "array (6 items)" }. Each violation also carriesexpected(what the schema declares at that spot — a type, format, allowed values, or bound; for a cross-field or business-rule check it is the fixed markera value satisfying the schema's custom check (see message), telling you to readmessagefor the authored specifics) andreceived(the KIND of value you sent —string,number,array (6 items),missing— or, for undeclared extra keys, their proven, secret-gated names — never the value itself). The field sits at a different place on each surface: CLI and HTTP callers read it aterror.details.violations(alongside the legacyfieldErrors), while MCP tool callers readviolationsat the top level of the error result, next tocodeandhelp— MCP results have nodetailsenvelope, sodetails.violationsisundefinedthere. Paths are real JSON paths into YOUR OWN submitted payload: a caller-supplied key (e.g. inside acapabilitiesorheadersmap) is echoed exactly, and an array position reports asxs[3].on, because a path into input you supplied tells you nothing you do not already hold. A segment shows as*only when the server could not prove it walks your payload — a segment no schema declares and your payload does not contain (a refinement can report a path that was never in your input) — or when the segment name is itself credential-shaped and withheld as secret material. So read*as "withheld, unproven", not as "this was your key" — the field names around it are still exact, and thecodestill tells you which constraint failed.limitis likewise only present when the schema itself declares that bound, so its absence means "not proven", not "no bound". The error envelope'shelpnames the exact next step —rost command schema <id>prints the full input contract, and when the payload came from--input-file, fix the file and re-run it. - Edit a draft:
charter.update_draft/rost_update_charter_draftwith thecharter_version_idand the newdoc.
In the app, /charters is both a drafting queue and a governance library. It shows active Charters, draft amendments, seats missing Charters, unsigned manifests, and steward-review gaps. /charters/builder is the seat picker for Charter Builder: start there when you need to draft a missing Charter, continue an amendment, or review which seats are still missing operating contracts. Opening a live Charter is a review mode: read the active version, inspect its governance state, and open an amendment draft when the job needs to change. Active Charter content is never silently edited in place. Both charter.draft_all triggers on this page — the "Draft missing Charters" empty-queue card and the "Draft gaps" header CTA — render the run's effect distinctly rather than a bare success: a full run (outcome: "applied") shows an ordinary "Done" state, a run that skipped some seats ("partial") shows "Partial effect" plus how many seats were skipped and why (the app aggregates them into product copy — "17 skipped: seat not active" — while CLI and MCP callers still get the per-target target_id/reason values from the contract), and a run that drafted nothing ("none") shows "No effect" the same way — so a human running this from the UI gets the same self-correction signal the effect contract gives a CLI/MCP/HTTP caller, above.
Daily and weekly measurable rows can carry Signal metadata: unit, numeric target, direction, cadence, and source. When a human approves a Charter, those structured measurables materialize into tenant Signal definitions for that seat. Free-text, monthly, and quarterly measurable prose remains accountability text until a human converts it into a supported structured Signal or creates the Signal separately.
When a declared Signal already exists
Preview first. Approval requires an explicit choice for a declaration that COLLIDES with an active exact-name Signal and is not already linked to one: adopt it if it is already owned by the Charter Seat, move it if another Seat owns it, or deliberately create a second Signal. A declaration nothing matches is created automatically, and one already linked to a Signal takes no choice. Adoption and movement preserve the Signal ID, readings, and compatible goal bindings.
charter.preview_signal_reconciliation is the owner-only dry run: per declaration it reports whether the declaration is already linked, which existing Signals match its name, who owns them, how much reading history and how many goal links they carry, the action that fits, and what that action would cost. charter.reconcile_signals then approves the Charter carrying one explicit decision per COLLIDING, UNLINKED declaration, and takes the edit_revision the preview returned so a draft edited in between is refused rather than reconciled against a stale reading.
Both commands are owner-only. Ordinary charter.approve stays seat-scoped and cannot take ownership of another Seat's Signal: when a declared name collides, it refuses and says an owner must decide. A Signal that already sets the status of a goal owned by another Seat cannot be moved until that goal's status driver is rebound — informative goal indicators survive a move.
When to stop for confirmation
charter.approve, charter.skip, charter.apply_seat_type_recommendation, and charter.sign_manifest are human_required. Over MCP rost_approve_charter returns a pending confirmation, not an activation. Approving a Charter, signing a permission manifest, or expanding authority is a human act recorded against decided_by. An agent prepares the draft and surfaces the approve link; it does not approve. See the confirmations guide.
Approval rule
Drafting can be assisted by agents. Activation is a human decision. When authority, tool permissions, credentials, or go-live status changes, route through confirmation.