Standing rules

A standing rule is a current instruction that governs behavior. It is short enough to check without interpretation, hung on a trigger, and able to leave force with a reason.

It is not a memory and not a fact.

  • A memory is something that happened or was said — recalled, associated, importance-weighted, Hebbian-linked. Measured on prod before this shipped: 494 of ~2,600 memories were type Instruction — a subsystem wearing a memory's clothes. Narratives are the right form for a lesson and the wrong form for a yes/no check at a decision point.
  • A fact is a key-value claim with optional validity windows. Facts answer "what is true"; rules answer "what binds now."
  • A standing rule is distilled from memories and links back to them. The narrative stays; the checkable assertion is separate. Without the story a rule is CLAUDE.md again. Without the assertion you re-extract the lesson under momentum and miss it.

All endpoints require authentication. Responses are wrapped in ApiEnvelope<T>. Canonical API host: https://api.atamaia.ai.


Why triggers exist

Without a trigger, every rule is evaluated on every action. That is wallpaper: a payload full of always-on instructions that nobody believes when they fire. Triggers narrow the moment so a reader seeing a rule fire has reason to trust it.

Trigger When
SessionStart Hydration at session start (lightweight win: short assertions instead of re-deriving from narrative)
BeforeDeploy Before shipping to production
BeforeSchemaChange Before migrations, renames, shape changes to stored data
BeforeSecurityChange Before auth, encryption, permissions, or tenancy code
BeforeProdWrite Before production data writes, bulk updates, backfills
BeforeClaimingDone Before closing a task or reporting work complete
BeforeRecording Before writing a memory, fact, or handoff
Always In force at every check — use sparingly

Always is value 99 and is included whenever you call check for any other trigger. Tenant 1 carries five Always rules today (ids 3, 5, 10, 13, 14). A store full of Always rules recreates the flooding problem this design avoids.

Triggers are a C# enum added by migration, not SPA-editable. Help and the runtime resolve enums from typeof(TEnum), not from free-form lookup rows — a UI-added trigger would be invisible to code that branches on it. Adding a trigger is a deliberate code change.


Status — the load-bearing field

Status Meaning
Active In force. Appears in check and (for SessionStart/Always) in hydration.
Proposed Distilled but not confirmed. Does not surface. Does not govern.
Superseded Replaced by a newer rule named in supersededById. Row and reason kept.
Withdrawn Deliberately revoked and not replaced. Distinct from Superseded.

Withdrawn vs Superseded are different facts.
Superseded is "we decided something else instead." Withdrawn is "we decided this was wrong." Conflating them loses the reason. Memories could not do this: both rulings stayed findable and both looked current .

There is no DELETE on this surface. A rule leaves force by withdraw or supersede. Deleting would recreate the hole: a ruling that silently stops existing.


Kind

Kind Role
Practice Do this — prefer where the rule can be written that way
Prohibition Never do this — sometimes right; often a Practice not yet found
Preference Style/defaults a good reason can override

The store tends to accumulate hazards faster than recipes. Kind makes the imbalance visible: "40 prohibitions and 3 practices" is diagnosable. Write assertions in positive, actionable form where the rule allows — "Test the constraint before reporting it" beats "do not report an untested constraint."


Grounding and evidence — not Hebbian links

A rule links to memories on two poles (RuleLinkPolarity):

Pole Meaning
Grounding The incident that made the rule necessary — why it exists
Evidence An occasion it was followed and worked — why it is worth keeping

These are deliberately not Hebbian links. Hebbian links carry strength and co-activation count and strengthen with use — associative memory. A rule's grounding is a claim about provenance. It must not decay because nobody recalled it lately, and must not strengthen because two memories were read together. The link is either true or it is not: no strength, no co-activation.

Detail and hydration return titles and notes only, never memory bodies. A rule that drags six narratives into the payload is the flooding problem again. Full story is one memory_get away.

A rule with grounding and zero evidence after many sessions is a candidate for rewrite (wrong form) or withdrawal (wrong rule) — not silent eternity. Live prod: most Active rules have groundingCount=1, evidenceCount=0 (new store; evidence half does not write itself).


Check — the in-force set for the moment of action

GET /api/standing-rules/check?trigger={RuleTrigger}&identityId={optional}

MCP: rule_check.

Returns: Active rules whose trigger matches or is Always. Tenant-wide rules (identityId null on the row) always included; pass identityId to add that identity's scoped rules. Ordered shortest assertion first.

Does not return: Proposed, Withdrawn, Superseded.

Side effect: increments surfacedCount and sets lastSurfacedAtUtc on every returned rule. A rule that never surfaces has a trigger nobody hits — a fact about the rule, not the reader.

Live probes (tenant 1, 2026-10-25):

Call Result
rule_check SessionStart 5 rules — all Always (no dedicated SessionStart Active rules yet)
rule_check BeforeDeploy 6 rules — the five Always + id 8 (Deploy the consumer before removing the producer)

Hydration

GET /api/hydrate includes standingRules when the StandingRules source is enabled (default presets include it).

Hydration loads Active rules with trigger SessionStart or Always only — not the full check vocabulary. Each item:

Field Content
id, assertion, rationale, kind The rule
grounding List of { memoryId, title, note }
evidenceCount Count only — not evidence titles in the lean payload

Live lean hydrate for ash: five rules (the Always set), each with grounding pointers and evidenceCount: 0. Proposed rows (id 15 today) do not appear.


Propose is not activate

Machine-written rules enter as Proposed. They do not surface in hydration and do not appear in check until a human confirms them.

Why: auto-extraction from narrative inverted a decision . The agent is often the one who just learned why a rule is needed — so it must be able to draft. Enacting is a person.

POST /api/standing-rules/{id}/confirm   → Proposed becomes Active

MCP rule_propose always creates with Status = Proposed. REST POST /api/standing-rules accepts a status on the body (including Active) — the human/API path can create already-active rules; the agent path cannot skip confirmation.

Live: rule 15 is Proposed (Keep every inference box on the same build…), absent from check and hydrate. Rule 12 was Proposed when this doc was written and is now Active — confirmed through the endpoint above; rule 14 (Make an unsupported input fail loudly…) likewise, confirmed 2026-10-25.


New tenants start with the default set

TenantProvisioningService seeds every new tenant with the default standing rules (StandingRuleSeed.Defaults, 2026-10-25) — tenant 1's honed set carried over so a new tenant starts governed rather than green. Fifteen rules spanning: deploy order (consumer before removal, code before migration), verification honesty (the running system over the build; delegated workers give citations, not verdicts; absence reported as a search result), hygiene (read the notes, search before filing, docs in the same unit of work, mechanism over note), debugging discipline (two failed attempts, then read the resolved config), security (independent multi-model review of security code before ship; probe production before rating a finding), and fail loudly on unsupported input.

Design decisions, each deliberate:

  • Assertions verbatim, rationales neutralised. The assertions were already checkable and tenant-neutral, so they copied as-is. Rationales name the source tenant's infrastructure (its proxy, its inference boxes, its model bridge) — wallpaper to a reader without those things — so each is retold tenant-neutral, keeping the shape that makes a rule followed: what went wrong, what it cost, why the rule is the answer. NOT personalised with the tenant's human's name: that would fabricate provenance (the new owner never ruled on these), and names in stored text go stale. Personalisation lives at the render layer — the dashboard tile — never in the rows.
  • No grounding links. Grounding memories live in the tenant that earned them and do not travel; a link to a memory the reader cannot open is worse than none. The rationale carries the story. A tenant links its own incidents over time — on the Evidence pole when a rule holds.
  • Generalised, not omitted, where the mechanism is local. The security-review rule seeds the principle ("run a security panel over the diff when one is available"), not the source tenant's particular panel — so an AI surfaces the rule wherever security work is done and reaches for whatever review mechanism the tenant has.
  • Tenant-wide, Active, owing nothing. identityId and projectId are null, status is Active, and no review or expiry date is set — a seeded reviewByUtc in the past would surface every rule stale on day one.
  • Idempotent by "any rule in this tenant" — the same rule the initial project follows: a re-run never double-seeds and never resurrects a rule a tenant removed.

Seeding is provisioning-internal: no API surface of its own. A new tenant meets the seed on the dashboard's Standing rules tile, where every rule can be withdrawn or (for Proposed) approved — the gift becomes a choice. Tests: StandingRuleSeedTests.

Boot reconciliation — the seed reaches tenants that predate it (#1128)

At startup, StandingRuleSeed.ReconcileAllTenantsAsync seeds any live tenant holding ZERO rule rows — once, and never again. This is #858's boot-reconcile shape applied to rules, and it is what carried the seed to the tenants provisioned before the seeder first deployed. The guard is deliberately "any row", not "any Active row": the store has no DELETE verb, so a tenant that curated every seed away still holds its Withdrawn rows and is skipped — boot reconciliation can fill the never-seeded hole, but can never overrule a tenant's curation.


Withdraw

POST /api/standing-rules/{id}/withdraw
{ "reason": "required string", "supersededById": null }
Body Resulting status
reason only Withdrawn
reason + supersededById Superseded, link to replacement

Reason is required at the service layer. Self-supersede and missing replacement id are rejected.

MCP: rule_withdraw.


CRUD surface

GET    /api/standing-rules
GET    /api/standing-rules/{id}
GET    /api/standing-rules/check
POST   /api/standing-rules
PUT    /api/standing-rules/{id}
POST   /api/standing-rules/{id}/confirm
POST   /api/standing-rules/{id}/withdraw
POST   /api/standing-rules/{id}/links
DELETE /api/standing-rules/{id}/links/{memoryId}

List

Optional: identityId (includes tenant-wide when set), status, trigger, paging, sort, filter bag.
When identityId is set, results are that identity's rules plus tenant-wide — asking "what governs Ash" and getting only Ash-specific rows would look complete and be wrong.

Create body

Field Notes
assertion Required; max 500; trimmed
rationale Why it exists — the cost of not having it
trigger, kind, status Enums
identityId, projectId Optional scope
links Optional initial grounding/evidence

Link body

{ "memoryId": 3125, "polarity": "Grounding", "note": "one line" }

Idempotent on (rule, memory): re-post updates polarity/note (a pole change is a real correction).

MCP tools

Tool Role
rule_check In-force set for a trigger
rule_list Browse/audit including history
rule_get Detail with grounding + evidence poles
rule_propose Create as Proposed
rule_add_evidence Link memory as Evidence
rule_withdraw Out of force with reason

REST-only relative to this MCP set: update metadata, confirm, unlink, create-as-Active.

SPA

Two surfaces, deliberately different shapes:

  • Dashboard tile ("Standing rules", in the default layout, full-width): the human-facing surface. Lists Active and Proposed rules with assertion, trigger in plain language, and the rationale behind a "Why?" toggle. Proposed rules carry an Approve button (one click); every rule carries Withdraw, which asks for a reason — prefilled and editable, so "one click away" stays true while the withdrawal record still says something (#245). Identity-scoped rows show their owner's name; a write against another identity's rule is refused server-side (#458) and surfaces as a toast, never a silent no-op.
  • Generic grid (/ais/standingrules, under "Your AIs"): the full resource view — every status, filters, the leave-force trail. No confirm/withdraw row actions yet; that is descriptor-actions territory (#505 tracks the related gaps).

Scope

Scope Meaning
identityId null Tenant-wide — governs everyone
identityId set That identity only (plus tenant-wide when listing/checking with that id)
projectId Optional confinement on the entity; check/hydration filter by identity today

Identity-scoped reads go through the same identity-access guard as other identity surfaces : hierarchy may oversee; only the owner writes.


What this replaces (and what it does not)

Replaces: treating Instruction memories as the standing-order mechanism; hoping two contradictory memories will sort themselves out; stuffing more lines into CLAUDE.md.

Does not replace: the memory store. Grounding is a memory. The narrative is where the WHY lives. Rules without sources become bare commandments.

Does not yet: confirm/withdraw actions on the generic grid — the dashboard tile carries the human-facing acts (2026-10-25); #505 tracks the grid-side gaps. Evidence accumulation is manual (rule_add_evidence) — nothing auto-writes the positive half.


StandingRuleDto fields (list / check)

Field Notes
id, guid D3
assertion, rationale
trigger, kind, status enum names on the wire
identityId, identityName, projectId scope
supersededById, statusReason, statusChangedAtUtc leave-force trail
groundingCount, evidenceCount pole counts
surfacedCount, lastSurfacedAtUtc usage signal
createdAtUtc, updatedAtUtc

Detail adds grounding[], evidence[] (titles/notes), supersededByAssertion.


Last updated