Repository navigation
security: a branch bound key never acts outside its branch - #83
Open
futurebuildai wants to merge 66 commits into
Open
futurebuildai wants to merge 66 commits into
futurebuildai wants to merge 66 commits into
Conversation
The sweep behind this test (PR body) found every route a machine key
reaches that writes or reads another branch's rows without the branch
middleware: the location module's unwalled writes (PUT and DELETE
/locations/{id}, POST /branches, PUT and DELETE /branches/{id}, the
user grant, revoke and home branch routes), the exposure surface's by
id routes on quotes, and the unwalled branch reads. ADR 0007 section
5.5 pins a bound key to its branch; only the branch middleware and the
payload guard honoured the pin, and these routes mount neither.
The tests drive serve's real mounts (branchWall.locations,
registerExposureRoutes) behind the real machine key core with a really
minted bound key, an unbound key and a user. All four fail at 1a719fd
on exactly the escapes: the bound key edits, archives, grants, revokes
and reads branch B's rows, writes an exposure event on branch B's
quote, and runs the dealer wide exposure scan.
Signed-off-by: colton <colton@futurebuild.ai>
…rywhere ADR 0007 section 5.5 pins a branch bound machine key to its branch, but only the branch middleware honoured the pin, and a machine key passes the role guards on its scope alone, so every route that mounted no branch middleware let a bound key act on any branch its body or path named: the location module's by id writes, the branch directory verbs, the user grant, revoke and home branch routes, the exposure surface's by id quote routes (whose actor fallback admits a key under AUTH_MODE=dev), and the dealer wide exposure scan. The wall (pkg/middleware/keybranch.go, beside the branch guard) reads the pin through branchctx.KeyBranchFromContext and compares the branch the request names by a path value, a body branch_id, or a row lookup (locations through their tree, quotes through their branch): a bound key naming another branch is refused 403 forbidden in the ADR envelope naming the field, with the key.branch_refused audit row, the same verdict the X-Branch-Id rule writes; its own branch passes, and a user and an unbound key pass unchanged, whatever the multi_branch_enabled switch says. The directory create and the scan refuse a bound key outright, being outside every pin. The branch users read of another branch is refused the same way, and a user's branch list is narrowed to the pin for a bound key. The wall mounts at the route registrations: the location handler takes it through WithKeyBranchWall (serve's branchWall builds it with the audit logger), and registerExposureRoutes composes it onto the exposure handler's guard. The two exposure lists and the branch directory reads stay un-narrowed: narrowing them changes recorded contracts (the exposure repository, PR 39's reference data decision) and the sweep lists them for the lead. Signed-off-by: colton <colton@futurebuild.ai>
CONTRACT-CHANGES gains the SEC-BOUND-KEY-PIN row stating exactly what changed on every route the key branch wall now covers. ADR 0007 5.5 gains the sentence that says how a route without the branch middleware is held to the pin and why the directory create and the dealer wide routes refuse a bound key outright. The drafts and locations module docs' bound key paragraphs now describe the fixed behaviour: the gap text (an operator should not mint a branch bound key with users:grants, branches:write or locations:write) is gone, replaced by what each route answers, with the branch directory reads, the exposure lists and the events feed named as the stated limits the sweep listed for the lead. Signed-off-by: colton <colton@futurebuild.ai>
The promotion test flips multi_branch_enabled globally with no lock, and a package whose fixture reads the setting mid flip (CI: the invoice credit memo test's sales caller) caches the flipped state for its middleware's 30 second TTL and answers 400 X-Branch-Id header required where its role guard owns the 403. Every other settings flipper (the serve wall and key pin fixtures) already rides the outbox lock; this one now does too. Signed-off-by: colton <colton@futurebuild.ai>
…ound-key-pin Signed-off-by: colton <colton@futurebuild.ai>
…er, red first The second review proved the wall and the handler read the body differently: the wall's json.Unmarshal fails on a body with trailing data and so names no branch, while the grant and home branch handlers' json.Decoder reads the first value and ignores the rest, so a key bound to A wrote a grant for B and moved a home branch to B with no audit row. Five bodies from the review, red at 7ef2d1d: all five land the write with 204. Signed-off-by: colton <colton@futurebuild.ai>
BodyBranch parsed with json.Unmarshal and let an unparsable body through as naming no branch, while the grant and home branch handlers decode with json.Decoder and read only the first value: a valid object followed by trailing data passed the wall and the handler acted on the first value. The wall now decodes into the same branch_id uuid.UUID the handlers decode, and a body that is not exactly one complete JSON value is refused for a bound key with the same 403 and key.branch_refused row as a foreign branch, so the wall and the handler can never disagree about what a body names. A body that parses and names no branch (absent, null) still passes to the handler's own 400, and the explicit nil UUID now meets the handler's branch_id required check rather than a wall refusal. Users and unbound keys never reach the parse. Signed-off-by: colton <colton@futurebuild.ai>
The sweep the reviews ran past the wall: the reporting module in all three of its registrations, the events feed, the two exposure lists, the GL, AP and bank reconciliation reads, the sales team reads, the known users list and the market index refresh are all open to a branch bound key today (30 routes, each one landing its module answer instead of the 403), while the unbound key and the user already answer the same as each other on every route. Signed-off-by: colton <colton@futurebuild.ai>
The lead's ruling on the reads and writes the sweep missed (TO-PRINCIPAL 51): every reporting route in its three registrations, the events feed, the two exposure lists, the GL, AP and bank reconciliation reads, the sales team reads, the known users list and the market index refresh now refuse a branch bound key outright with the key.branch_refused row. The GL, AP and bank reconciliation writes carry no branch fact and keep the role guard alone for every principal, as does the index metadata and history beside the refresh. Users and unbound keys keep exactly their reach: the test walks all thirty routes with the unbound key and the user and they answer the same as each other, 200 on the reads that answer data. Signed-off-by: colton <colton@futurebuild.ai>
The lead's ruling on the customer scoped writes: the tax exemption writes and the customer priced rules (plain and category, single and bulk) are open to a branch bound key today, so a key pinned to A writes branch B's customers' data: eight write families land their module answer instead of the 403, with the rows written. The unbound key and the user answer the same as each other on every route, each against its own fresh target. Signed-off-by: colton <colton@futurebuild.ai>
…the customer's branches The lead's ruling on the customer scoped writes: the tax exemption writes and the customer priced rules (plain and category, single and bulk) now reach a branch bound key only while every customer the request names, or that the row it acts on belongs to, holds the pin among the customer's branches (customer_branches), the same visibility the customer module holds a caller's branch context to; anything else is the 403 refusal with its key.branch_refused row, the details naming customer_id. A request that names no customer passes (it writes dealer wide data), a body that is not exactly one complete JSON value is refused like the body branch wall refuses it, and a lookup failure answers 500 rather than failing open. The reads keep the role guard alone; users and unbound keys never reach the wall. Signed-off-by: colton <colton@futurebuild.ai>
…ncluded pkg/middleware had no test file for keybranch.go at all, and the first review's mutant M6 (a branch lookup error fails open) survived every test. The unit tests walk every constructor with a recording auditor and no database: a pin and a foreign branch by path, body (whole, or refused), row and customer, the unparsable and trailing data bodies, the malformed and missing ids that pass to the handler's own answers, the no pin pass through, and the failure paths that must never reach the handler: a lookup error and a nil database answer 500. Applied alone, M6 now dies on the lookup error case (the handler ran, want the wall to fail closed). Signed-off-by: colton <colton@futurebuild.ai>
The second review seeded a legacy location row with a NULL branch (user triggers lifted) and the wall passed it: the bound key PUT and DELETE answered 200 and 204. ErrRowNamesNoBranch now marks a row that exists but belongs to no branch, and RowBranch refuses a bound key it like a foreign branch, red first. The switch off case pins the wall's independence from the multi_branch_enabled switch: with the switch off the pin still refuses the foreign branch and passes its own, and the unbound key and the user keep their reach. Signed-off-by: colton <colton@futurebuild.ai>
ADR 0007 section 5.5 now states the wall's whole final behaviour: the whole body read, the outright refusals (the directory create, the exposure scan and lists, the market index refresh, the events feed, every reporting route, the dealer wide GL, AP and bank reconciliation reads, the sales team reads, the known users list), the customer_branches confinement of the customer scoped writes, and the branch directory reads limit in one sentence with the PR 39 reference data reason. drafts.md and locations.md say the same for their routes, locations.md adds the home to own branch side effect on the user's other grant rows, the CONTRACT-CHANGES row lists every route and the 403 for a missing branch id where the base answers 404, and its stray trailing blank line is gone. Signed-off-by: colton <colton@futurebuild.ai>
…ed first The pin fixture now drives the production chain (buildChain) instead of a hand built machine key mount, mounts the staff roster, seeds a job per customer, a tier category rule and a certified tax exemption, and the round 4 findings become assertions: the refresh preview, the GL, AP and bank reconciliation writes, the staff roster, the unfiltered pricing lists and the matrix refuse a bound key; the customer scoped reads (the exemption read by path customer, the category rule list by query customer, the rule audit, calculate, resolve, the tax preview by body customer) are confined; a pricing rule naming a job is confined through the job's customer; a rule naming neither a customer nor a job, and a tier category rule on every verb, are refused; the stated limits (the branch directory reads, the index metadata and history) stay open; and a path no route resolves is refused. The GL writes block that held them open is gone: the ruling changed. Four of the five new tests fail on the base head, the stated limits test pins the open behaviour. Signed-off-by: colton <colton@futurebuild.ai>
One table, core/pkg/middleware/keybranch_allow.go: every route a machine key can reach (345 of them, method plus pattern exactly as the census names them) carries exactly one class. WALLED (183) runs behind the branch middleware; CONFINED (28) is held by the key branch wall or the customer branch wall, each entry naming its test; STATED LIMIT (69) is reachable unconfined by design, each entry with its one line reason; REFUSED (65) answers a bound key 403 forbidden with the key.branch_refused row. The check runs once: KeyBranchRouteGate, mounted in buildChain inside auth (it reads the pin the machine key core sets) and outside the confirm gate and idempotency (a refused request claims nothing). It resolves the request's registered pattern through the serve mux and refuses a bound key every route the table does not admit, including a route the router resolves to no pattern: a missed mount can no longer fail open, whatever the wiring beneath the chain does. A request with no pin passes untouched. The per mount refusals the opt in approach added are gone (the gate is the one place refusals live): the reporting, events feed, GL, AP, bank reconciliation and sales team mounts return to their role guards, the index admin guard loses its suffix matching (which missed the refresh preview), the exposure wall keeps only its by id confinement, and the location handler drops the directory create and known users walls. RefuseBound leaves the wall's API with its test. The census test fails when a machine key reachable census route is unclassified, when an entry matches no census route, and when a confined or stated limit entry carries no justification; it runs in the untagged suite with no database. TestKeyBranchPin_AllowlistGate- CoversCensus drives every reachable census route through the production chain with stub handlers: the bound key is refused exactly the 65 REFUSED entries, and the unbound key passes all 345. The pin fixture now builds its chain with buildChain, the builder Run uses. Signed-off-by: colton <colton@futurebuild.ai>
The customer branch wall now resolves every field that names a customer scoped row, not only customer_id: - a pricing rule's job_id resolves to its projects row's customer, and a body naming a customer and a foreign job is refused (both are checked), closing round 4 P1: a bound key can no longer set the price a branch B customer pays on a branch B job with no audit row; - a rule that names neither a customer nor a job is a dealer wide write and is refused (round 4 P2), as is a category rule or bulk element that names no customer (a tier target prices every branch), through CustomerBodyBranchRequired, which refuses an object or element with no customer_id; - the customer scoped reads are confined like the writes (round 3 P1-2, round 4 P2): the exemption read by its path customer, the category rule list by its query customer (absent is the unfiltered list and is refused), the rule audit by its row's customer, calculate and resolve by their query customer, and the tax preview by its body customer when it names one. A category row that targets a tier names no customer and is refused on every verb, and a bulk delete naming a tier row is refused whole. The walls dispatch on r.Pattern, the registered pattern the mux sets, not on path suffixes or prefixes. Signed-off-by: colton <colton@futurebuild.ai>
Round 3 P3-1, both arms now pinned: the location row lookup's SQL error arm fails closed (a cancelled query answers 500 with the handler never reached; the mutant that swallows the error into no branch named makes the test fail), and a bulk category delete body that is not exactly one complete JSON value is refused for a bound key (the decode error mutant makes the test fail). Both mutants were green on the previous head. Round 3 P2-2, the source half: the behaviour tests drive the fixture's mounts, so Run() dropping a confinement call would leave them green while production lost its wall. TestServeWiringMountsTheConfinements pins serve.go's five confinement call sites and chain.go's gate mount; the behaviour half (the fixture builds its chain with buildChain, and TestKeyBranchPin_AllowlistGateCoversCensus drives the gate over every reachable census route) makes a dropped gate fail red on its own. Signed-off-by: colton <colton@futurebuild.ai>
ADR 0007 section 5.5 now states the deny by default rule itself: the four classes, the one gate, the census test, the completed confinement contract (jobs resolve to their projects row customer, the customer scoped reads are confined, dealer wide writes are refused), the stated limits each with their reason, the do not mint advice round 3 P3-2 asked for, and the refused surfaces (the GL, AP and bank reconciliation writes included). The SEC-BOUND-KEY-PIN row, the drafts module page and the locations module page say the same shape. Signed-off-by: colton <colton@futurebuild.ai>
Signed-off-by: colton <colton@futurebuild.ai>
…ound-key-pin Signed-off-by: colton <colton@futurebuild.ai>
…e key branch wall Round 6 P1-1: a tier category rule that also named a customer passed the customer body wall (it reads customer_id only), was written, and priced every branch's customers of that tier while the allowlist said tier targets are refused. The wall's category body resolver now decodes the same target the handler parses (target_type, tier, customer_id, per element of a bulk body) and refuses any element whose target is not an account rule or that carries a tier; the handler keeps the invariant on its own side (a tier rule carries no customer, an account rule no tier); migration 107 puts the invariant on the table, checking existing rows first: a row that breaks it is reported by a NOTICE naming the count and ids, never rewritten, and the constraint goes on NOT VALID when any exists, validated on add otherwise. Signed-off-by: colton <colton@futurebuild.ai>
Round 5 P1 and round 6 P1-2: the till, Z report, return and account routes loaded their row by id with no branch predicate, so a key bound to A closed B's till (posting its over/short and freezing its Z report), read B's till report, Z reports, returns, account summary, AR statement and reconciliation, and a line insert on B's transaction left the line written behind its 500. The till, Z report and return reads now carry the branch predicate the transaction reads already carried (till_sessions.branch_id, till_z_reports.branch_id, pos_returns.branch_id); the line delete joins its transaction's branch; AddItem reads the transaction (through the same predicate) before anything is written, so the insert never happens on a transaction the caller may not target; the account summary, subledger and statement mount the customer branch wall on the path customer; and the reconciliation, whose ledger half carries no branch fact, is refused to a bound key by the route allowlist. The pos handler file is untouched (PR 72 rewrites it): the confinement lives in the repository queries and the branch wall layer. Signed-off-by: colton <colton@futurebuild.ai>
…tomer Round 5 F-3: GET /pricing/calculate confined only its customer_id, so the pin's customer plus the foreign customer's job answered with the job override rule that prices the foreign customer, byte for byte the unbound key's answer. The calculation's resolver now resolves job_id to projects.customer_id, the same resolution the rule write carries, and holds both customers to the pin; a job that names no customer changes nothing. Signed-off-by: colton <colton@futurebuild.ai>
…s customer Round 6 P3: POST /tax/preview with the foreign branch's ship_to_id and no customer passed the wall (the resolver read customer_id only) and answered a rate for a delivery the key's branch does not serve. The preview's resolver now resolves ship_to_id through customer_ship_tos.customer_id and holds that customer to the pin beside the body's customer_id; a preview naming neither stays a plain rate calculation. Signed-off-by: colton <colton@futurebuild.ai>
…ion path probe
Round 5 P3: the gate fails closed when its mux is not a pattern resolver
(mutant G5 survived every test; the pin drives a plain handler mux and
expects the refusal, and goes red under the mutant); the twenty REFUSED
routes no independent test named (staff id and modules, reporting saved and
schedules, AP invoice read and approve, bankrecon session read, account
create, complete and unmatch, GL reopen, post, reverse, void and account
write) join DealerWideReads by name with their parity legs; and
PUT /locations/{id} writing path is probed and pinned: the path is a
materialized breadcrumb with no enforcement, the branch comes from the
parent at insert (the denorm trigger fires on parent_id and type, neither
writable by the route), and no branch scoped read resolves a branch from a
path, so a path naming the foreign branch's breadcrumb cannot move a row
under it.
Signed-off-by: colton <colton@futurebuild.ai>
…er writes it The principal's ruling (FROM-PRINCIPAL 37 and 38): every STATED LIMIT write is refused to a branch bound key, the reads stay stated limits; the dealer wide catalogue writes the branch middleware cannot confine (products, PIM content, margins, dimensions, lead time, kit components, unit sets, payment terms, charge codes, the reorder target refresh) are dealer wide writes and are refused with their reads stated; and the driver routes are refused outright, reads included (PD-3: driver rows carry personal data and no branch column), while the vehicle reads stay stated limits. The class split in the table: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118 of the 345 machine key reachable routes. One A/B leg per write family drives every refused family with its audit row beside the unbound key's own module answer, and the margin write proves the refusal is the only thing that kept the dealer wide target margin unwritten. Signed-off-by: colton <colton@futurebuild.ai>
… a twin sweep The lead's ruling on item 37: WALLED becomes a tested claim. Every WALLED entry's note now names the column or join its module filters on (quotes, orders, invoices, payments, credit memos, purchase orders, drafts and till rows on their branch_id; customers, contacts, ship tos and activities through customer_branches; deliveries and routes through their orders' branch; the dashboard and inventory through locations.branch_id; the links through each module's walled read), replacing the bare the branch middleware label that rounds 5 and 6 showed can overstate. A new sweep drives every WALLED route that takes an id or a list with branch B's twin row as a bound A key's target: the foreign row answers exactly what a missing row answers (or a 403), never names the row, nothing is written (family tables counted before and after), a user reads the twin where the missing row is a 404 so the twins are real, and the lists carry the pin's branch only. The fixture grows the document family mounts exactly as serve registers them and a second mint pair for the document scopes (the mint caps a key at 64 scopes). Signed-off-by: colton <colton@futurebuild.ai>
…mers, audited grants and AI spend PD-4, the stricter line: a branch bound key may read a customer shared with another branch, but may not add or change a customer scoped pricing rule, category rule or tax exemption for it, because the write prices the other branch's sales to that customer too. A new exclusive customer wall holds those writes to the customers the pin holds alone and refuses the rest with a message naming the other branch; the reads keep the shared customer rule. PD-5: the grant, revoke and home branch writes for the pin branch record their audit rows (user.branch.granted, user.branch.revoked, user.home_branch.set) with the machine key named as the actor. PD-6: the parsing upload and the vision scan stay stated limits and each call records its ai.parsing.upload or ai.vision.scan audit row with the key that spent the dealer's AI credit (both handlers gain the audit logger serve wires). The drivers ruling (PD-3) landed with the stated limit split. Signed-off-by: colton <colton@futurebuild.ai>
…e code now does The ADR's section 5.5 records the principal's rulings: WALLED is a tested claim whose entries name their filter column; the category rule invariant and its migration; the stricter shared customer line (read yes, priced writes no, the refusal naming the other branch); the audited grant, revoke and home branch writes; the stated limit as a read, never a write (the catalogue and configuration writes, the drivers refused reads included, the AI spend audited); and the account and calculation confinements. CONTRACT-CHANGES gains the fix round 3 row naming every changed answer, and the delivery, charge codes, products and locations module pages state the same: the fleet split, the refused catalogue writes, the audited grants. Signed-off-by: colton <colton@futurebuild.ai>
…shared customer Principal rulings 43 to 45. On a customer held by the pin's branch and another, a bound key may change only the branch local fields (email, phone); every other field of the record, the salesperson and the escalation policy is restricted by default (a field added later is restricted until listed as branch local), and the key is refused in the PD-4 words naming the other branch, with the key.branch_refused row. It may add a ship-to or a contact but change or delete none of a shared customer's. A customer held by the pin alone, a user and every unshared customer are unchanged. A dealer wide key changes the restricted fields and each change is audited as customer.shared_restricted_changed with the actor and the before and after value of every field. The rule lives in the customer service (it must see the field diff) behind a SharedGuard that customerguard adapts from the key branch wall. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: colton <colton@futurebuild.ai>
…n ids
Round 8 and 9 found the sweep vacuous: it sent {} bodies that failed validation
before the wall was reached, counted whole tables that other packages move, and
skipped about 25 walled routes. Every write leg now sends a body that passes
validation, shadowed by a control against the pin's own twin that must pass the
module's validation and reach the row (a leg with no possible control names why);
the foreign leg must answer 403 or 404 and never a scope refusal; changes are
measured by a digest of every row that names branch B's twin rows by id, never
by table counts. A coverage test holds the route allowlist against the legs and
fails by name for any WALLED route no bound key leg drives. The fixture now
builds the order service as serve does (price engine, exposure gate, quote
convert) and the draft change feed, and removes the rows and keys it makes.
The sweep found a real gap: the order exposure gate and override looked up the
order's source quote by id alone, so a key pinned to A could read the gate of,
and clear the exposure of, a quote behind branch B's order. Both now read the
order under the branch wall first.
Also: the branch named in the "also held by" refusal is the lowest numbered
branch that is not the pin, the same answer every time.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: colton <colton@futurebuild.ai>
Round 7 P2-2: a migration 107 test over a scratch database holds the check violation, the NOTICE naming the rows, the unrewritten rows, the NOT VALID branch with planted rows and the validated branch without them. P2-3: the shared customer writes test now drives DELETE and POST on the category bulk routes and the audit read of a shared customer's rule. P2-4: the AI spend and grant assertions measure a delta by action name and by the key's own id; serve.go's own WithAuditLog calls for the location, parsing and vision handlers are pinned in the source level wiring test. P3: the dealer wide AP leg drives /transitions (the renamed route); the parsing upload is audited before the extractor runs, so a failure after the AI call still records the spend; the AP refusal notes call the dealer wide books a policy choice. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: colton <colton@futurebuild.ai>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: colton <colton@futurebuild.ai>
…hanges and the module pages The sweep claim now says what it does (valid bodies, an own twin control, the twins' ids, a coverage test), the body customer walls and the order ship to and contact ownership are stated, and the shared customer rule carries its field by field table (RESTRICTED or BRANCH LOCAL, with the reason) and the ship to and contact rule. A SEC-BOUND-KEY-PIN-4 row joins the contract changes; the customers, orders, quotes, payments, invoices and drafts pages carry the rule that touches them. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: colton <colton@futurebuild.ai>
Signed-off-by: colton <colton@futurebuild.ai>
…o the pin Round 10 P1-1: POST /api/v1/pos/sync takes a customer on every replayed sale and no wall held it, so a key pinned to branch A posted a sale (and an ACCOUNT tender's invoice) on a customer held only by branch B. The mount wall.pos composes the key branch wall's customer hold over the body's items[].customer_id on the sync route only; the change lives in the wall layer and the route allowlist, with no pos code touched. Red first: TestKeyBranchPin_PosSyncCustomerWall fails on 4bad569 with the sale synced and the foreign invoice written. Signed-off-by: colton <colton@futurebuild.ai>
Round 11 P1-1 and round 10 P2-2: an order or a quote took a job_id of another customer's project, and the order was then priced by that customer's job rule. For every caller the order build (and the conversion's buildFromQuote) and the quote priceDraft now refuse a job_id whose project belongs to a different customer than the document's, 400 naming job_id with the credit memo's words; the order repository's OwnedBy carries the job beside the ship-to and the contact. For a branch bound key the wall holds the body's job_id through projects.customer_id on the order and quote creates and updates (bodyDocumentCustomer over documentCustomersOf), the same resolution the pricing calculate read carries. Red first: TestKeyBranchPin_JobOfAnotherCustomer fails on 4bad569 with the foreign job stored and the override's price on the A control. Signed-off-by: colton <colton@futurebuild.ai>
Round 11 P2-1: a key pinned to A converted a quote at branch A whose
customer was held only by B, and the created order took B's tax
exemption. The conversion is the one path where an existing document
becomes a new one, so the quotes mount walls POST /quotes/{id}/convert
through the quote's stored customer (quoteRowCustomerOf), as the draft
promotion rechecks its payload: a quote a caller moved onto a foreign
customer is refused 403 naming customer_id with nothing written. Red
first: TestKeyBranchPin_QuoteConvertCustomerWall fails on the
pre-wall mount with the order created and B's exemption on it.
Signed-off-by: colton <colton@futurebuild.ai>
…ared customer Round 11 P2-2 and round 10 P2-1: naming is_default true on a ship-to add clears the customer's existing default, which on a shared customer is a shared ship to every branch's delivery orders default to, so the add changed a shared record through a route the rule classes as adds only. CreateShipTo runs checkSharedChild on the add that names is_default true before ClearDefaultShipTo, refused in the same words naming the other branch (the refusal now also goes through sharedOutcome so its audit row is written); a customer the pin holds alone, a user and a dealer wide key keep the reach. Red first: the SharedCustomerRecord leg fails on 4bad569 with the add 201 and the seeded default cleared. Signed-off-by: colton <colton@futurebuild.ai>
Round 11 P2-3: an activity of customer A named customer B's contact on the create and the update, for every caller, so one branch's record pointed at another's contact. The service checks the contact against the activity's customer (ContactBelongsTo) and refuses with the order's words, 400 naming contact_id. Red first: TestKeyBranchPin_ActivityContactOwnership fails on 4bad569 with the foreign contact written on the create and the update. Signed-off-by: colton <colton@futurebuild.ai>
…field scope Principal ruling item 48: a shared customer's restricted fields change only with the finance or owner role (a user) or an unbound machine key carrying a dedicated customers:restricted scope. The scope joins the grant grammar (ValidScopeGrammar, of no route, so no module scope ever grants it and no default grant carries it); the mint grants it only for an owner (the admin role is refused, fail closed without claims) and the mint's key.created audit row names it. SharedCustomer answers a fourth verdict, RefuseCaller: a user without the finance or owner role and an unbound key without the scope are refused in the PD-4 words naming the lowest numbered branch holding the customer; a bound key is refused even with the scope; the scoped key's change keeps its customer.shared_restricted_changed audit row. The customer routes' guard admits finance so the ruling's role can reach the fields. Red first: TestKeyBranchPin_SharedRestrictedScope fails on 4bad569 with the mint 400 and every caller change open. Signed-off-by: colton <colton@futurebuild.ai>
…t, not a 500 Round 11 P3-1: a PUT of the legacy tier row that also names a customer (the shape migration 107 leaves NOT VALID) revalidated the constraint on the write and answered 500 for the unbound key and the user. UpdateCategoryRule refuses such a row with a 409 naming the single target invariant before the write, for every caller; the bound key is refused at the pin first. Red first: the LegacyTierRowStaysDealerWide PUT legs fail on the unfixed code with the 500. Signed-off-by: colton <colton@futurebuild.ai>
… a customer Round 11 P3-2: the body customer wall held POST /payments/intent too, so a bound key naming any customer in a field the route does not accept got the pin's 403 while every other caller got the route's own 400 unknown field. The intent carries no customer, so its pattern leaves the body customer wall and the route's own 400 answers every caller alike. Red first: the BodyCustomerWall intent leg fails on the walled code with the 403. Signed-off-by: colton <colton@futurebuild.ai>
…nature date Round 10 P3-1, P3-2, P3-3, P3-5 and P3-6, and round 10 P3-4's code half: the credit memo PUT wall gets its refused leg (the pattern dropped from the invoices mount now goes red), the PUT body's salesperson_id gets its refusal leg (classing it BRANCH LOCAL goes red), the shared customer is held by a third branch so every also held by answer must name the lowest numbered other branch (a DESC on either lookup goes red), a service level refusal row names the method and path RecordRequest noted (removing RecordRequest goes red), the walled route coverage counts only explicit twin legs so an A control alone covers nothing (a driven but control only route fails by name), and the escalation audit's before and after values carry agreement_signed_at beside the reference (policyFacts, missing on the unfixed code). Signed-off-by: colton <colton@futurebuild.ai>
…odule pages ADR 0007 section 5.5 and ADR 0002 state the round 5 rules: the sync's body customer wall, the job_id wall and the every caller job, contact and default ship to ownership, the convert's customer recheck, the intent outside the body customer wall, the customers:restricted capability scope and the finance or owner user rule that resolves the round 4 open question, the legacy row's 409, and the twin only coverage. CONTRACT-CHANGES carries SEC-BOUND-KEY-PIN-5, and the orders, quotes, payments, customers, crm activities and tech admin module pages state each rule where the module states its own. Signed-off-by: colton <colton@futurebuild.ai>
Signed-off-by: colton <colton@futurebuild.ai>
The synchronize event of the round 5 push was not delivered: no run, check suite or check run exists for 88ca773 while the workflow kept serving every other pull request. An empty commit re-fires it; the tree is unchanged. Signed-off-by: colton <colton@futurebuild.ai>
…ound-key-pin Signed-off-by: colton <colton@futurebuild.ai> # Conflicts: # core/internal/drafts/feed_c5_2a_p2_1_test.go # docs/modules/tech-admin.md
Signed-off-by: colton <colton@futurebuild.ai>
…ound-key-pin Signed-off-by: colton <colton@futurebuild.ai>
…PR 83 r13 P2-1) CreateTransaction, CreateTillSession and GetRegisterBranch derived the register's branch only through locations, so a register whose location names no branch skipped the pin check and wrote at the default branch. COALESCE(l.branch_id, r.branch_id) makes the NOT NULL column the fallback. Tests per register branch derivation plus r13's reproducing pos sync test; the allowlist notes now say what the code reads. Signed-off-by: colton <colton@futurebuild.ai>
…nd key's words (PR 83 r13 P3-1) A user without the finance or owner role and an unbound key without customers:restricted were refused with different words from the bound key's and left no audit row. They now carry the bound key's words, and a customer.shared_restricted_refused row names the actor, the fields, the lowest numbered holding branch and the method and path. Signed-off-by: colton <colton@futurebuild.ai>
…y scoped bound key (PR 83 r12 P3s) Each test goes red when its guard is removed: the conversion's stored job recheck, ownerGrantsRestricted with no claims, and a bound key that carries customers:restricted on a shared customer. Signed-off-by: colton <colton@futurebuild.ai>
…PR 83, item 54 question 1) The customer group admitted finance on every route. A third role guard now admits finance on the header write, the salesperson and the escalation policy only; customer and contact create, the reads and the other writes answer finance what refactor/v1 answered. No ADR or role catalog row grants finance more. Signed-off-by: colton <colton@futurebuild.ai>
… 83, item 54 question 2) An activity has no branch tag, so on a customer another branch also holds a bound key adds one and never changes or deletes one: 403 in the contact rule's words with a key.branch_refused row, the activity untouched. The crm service takes the shared guard through the customer guard adapter. Signed-off-by: colton <colton@futurebuild.ai>
…and the module pages state fix round 6 Signed-off-by: colton <colton@futurebuild.ai>
…EG-01 onto Kelowna The till session opened on a register with no branched location now carries the register's branch, so branch_id appears in its body (the four pos goldens, ids renumbered). The seed moves REG-01 onto Kelowna so a seeded register belongs to the default branch the seed makes, which leaves every sale and return amount unchanged. Listed in CONTRACT-CHANGES. Signed-off-by: colton <colton@futurebuild.ai>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A branch bound machine key never acts outside its branch, denied by default. ADR 0007 section 5.5 pins a bound key to its branch; the opt in fix that walled each route the sweep found failed open on every route a round missed (rounds 3 and 4 kept finding siblings: the index refresh preview, the customer scoped pricing and tax reads, a pricing rule naming a branch B customer's job, the dealer wide pricing writes, the GL, AP and bankrecon writes). This round replaces it: one allowlist (
core/pkg/middleware/keybranch_allow.go) classifies every route a machine key can reach (345 census routes, method plus pattern), and one runtime check,KeyBranchRouteGatemounted once in serve's chain inside auth, refuses a bound key every route the table does not admit, a route the router resolves to no pattern included. A census test in the untagged suite fails when a machine key reachable census route is unclassified or a table entry is stale. A user and an unbound key are unchanged everywhere, whatever themulti_branch_enabledswitch says.The sweep
Every route a machine key can reach that does not run behind the branch middleware, and writes (or reads another branch's rows):
Writes with no branch fact (no escape, unchanged): units, parsing upload, pricing categories, category rules, rebates, market indices, vendors, gl, edi, ap, bankrecon, reporting, techadmin settings, staff and module switches, apps, vision, millwork, configurator, governance. Not reachable by a machine key at all:
/api/portal/*,/api/integration/*,/api/partner/*(the partner segment is not in the scope vocabulary),/api/v1/a2a,/api/v1/admin/keysand/api/v1/me(user only). Flagged for the lead and not fixed here: the tax exemption routes name a customer whose branch set is many valued (customer_branches), so refusing needs a policy for a customer that spans the pin's branch and another.Each refusal with its test
Every refused route is driven through serve's real mount methods (
branchWall.locations,registerExposureRoutes) behind the real machine key core with a really minted key, incore/internal/app/serve/wire_key_branch_pin_test.go. Each matrix is: bound to A against B (403forbidden, one freshkey.branch_refusedaudit row, no write), against A (allowed), unbound key and user (unchanged). All four tests were red first at 1a719fd (22 assertion failures, every one an escape, including a real cross branch exposure event write).TestKeyBranchPin_LocationWrites: PUT and DELETE/locations/{id}, POST/branches, PUT and DELETE/branches/{id}.TestKeyBranchPin_UserGrants: the grant, revoke and home branch routes, by body and by path.TestKeyBranchPin_LocationReads:GET /branches/{id}/usersrefused for another branch;GET /users/{sub}/branchesnarrowed to the pin for a bound key.TestKeyBranchPin_ExposureRoutes: the five by id exposure routes (foreign quote refused, own quote passes to the module: 200, 200, 200 with the event written, 409, 409), the scan refused for a bound key, unbound key and user unchanged, and the auth core's own X-Branch-Id rule standing in front.Docs
CONTRACT-CHANGES gains the
SEC-BOUND-KEY-PINrow stating exactly what changed. ADR 0007 section 5.5 gains one paragraph saying how a route without the branch middleware is held to the pin and why the directory create and the dealer wide routes refuse a bound key outright.docs/modules/drafts.mdanddocs/modules/locations.mdbound key paragraphs now describe the fixed behaviour, with the branch directory reads, the exposure lists and the events feed named as stated limits.🤖 Generated with Claude Code
Fix round 2: the allowlist (deny by default)
Class counts over the 345 machine key reachable census routes after fix
round 3 (the round 2 counts were WALLED 183, CONFINED 28, STATED LIMIT 69,
REFUSED 65):
key.branch_refusedaudit row)Every CONFINED entry:
DELETE /api/v1/branches/{id}DELETE /api/v1/locations/{id}DELETE /api/v1/pricing/category-rules/bulkDELETE /api/v1/pricing/category-rules/{id}DELETE /api/v1/tax/exemptions/{id}DELETE /api/v1/users/{sub}/branches/{branch_id}GET /api/v1/branches/{id}/usersGET /api/v1/pricing/calculateGET /api/v1/pricing/category-rulesGET /api/v1/pricing/category-rules/{id}/auditGET /api/v1/pricing/resolveGET /api/v1/quotes/{id}/exposureGET /api/v1/tax/exemptions/{customerID}GET /api/v1/users/{sub}/branchesPOST /api/v1/pricing/category-rulesPOST /api/v1/pricing/category-rules/bulkPOST /api/v1/pricing/rulesPOST /api/v1/quotes/{id}/exposure/acknowledgePOST /api/v1/quotes/{id}/exposure/escalate-nowPOST /api/v1/quotes/{id}/exposure/overridePOST /api/v1/quotes/{id}/exposure/request-ackPOST /api/v1/tax/exemptionsPOST /api/v1/tax/previewPOST /api/v1/users/{sub}/branchesPUT /api/v1/branches/{id}PUT /api/v1/locations/{id}PUT /api/v1/pricing/category-rules/{id}PUT /api/v1/users/{sub}/home-branchEvery STATED LIMIT entry:
DELETE /api/v1/admin/settings/aiDELETE /api/v1/admin/settings/routingDELETE /api/v1/delivery/drivers/{id}DELETE /api/v1/delivery/vehicles/{id}DELETE /api/v1/edi/partners/{id}GET /api/v1/admin/modulesGET /api/v1/admin/settings/aiGET /api/v1/admin/settings/routingGET /api/v1/appsGET /api/v1/branchesGET /api/v1/branches/{id}GET /api/v1/configurator/optionsGET /api/v1/configurator/presetsGET /api/v1/configurator/rulesGET /api/v1/delivery/driversGET /api/v1/delivery/drivers/{id}GET /api/v1/delivery/vehiclesGET /api/v1/delivery/vehicles/{id}GET /api/v1/edi/partnersGET /api/v1/edi/partners/{id}GET /api/v1/edi/partners/{id}/catalogGET /api/v1/governance/rfcsGET /api/v1/governance/rfcs/{id}GET /api/v1/market-indicesGET /api/v1/market-indices/{id}/historyGET /api/v1/matching/configGET /api/v1/millwork/optionsGET /api/v1/millwork/options/{id}GET /api/v1/price_levelsGET /api/v1/pricing/categoriesGET /api/v1/pricing/rebates/programsGET /api/v1/pricing/rebates/programs/{id}GET /api/v1/pricing/rebates/programs/{id}/claimsGET /api/v1/unitsGET /api/v1/units/{code}GET /api/v1/vendorsGET /api/v1/vendors/{id}POST /api/v1/apps/{key}/disablePOST /api/v1/apps/{key}/enablePOST /api/v1/configurator/build-skuPOST /api/v1/configurator/validatePOST /api/v1/delivery/driversPOST /api/v1/delivery/drivers/{id}/photoPOST /api/v1/delivery/vehiclesPOST /api/v1/delivery/vehicles/{id}/photoPOST /api/v1/edi/partnersPOST /api/v1/edi/partners/{id}/import-catalogPOST /api/v1/governance/rfcsPOST /api/v1/governance/rfcs/{id}/transitionsPOST /api/v1/millwork/optionsPOST /api/v1/parsing/uploadPOST /api/v1/pricing/calculate-escalationPOST /api/v1/pricing/categoriesPOST /api/v1/pricing/rebates/programsPOST /api/v1/pricing/rebates/programs/{id}/claims/calculatePOST /api/v1/unitsPOST /api/v1/vendorsPOST /api/v1/vision/scanPUT /api/v1/admin/modules/{id}PUT /api/v1/admin/settings/aiPUT /api/v1/admin/settings/routingPUT /api/v1/delivery/drivers/{id}PUT /api/v1/delivery/vehicles/{id}PUT /api/v1/edi/partners/{id}PUT /api/v1/governance/rfcs/{id}PUT /api/v1/market-indices/{id}PUT /api/v1/matching/configPUT /api/v1/pricing/categories/{id}PUT /api/v1/units/{code}The wall dispatchers now match
r.Pattern(the registered pattern), not path suffixes;job_idresolves to its projects row's customer besidecustomer_id(both checked); the customer scoped reads that name a customer in a path, query or body are confined like the writes; and a write that would be dealer wide (a rule naming neither a customer nor a job, a tier category rule, a bulk delete naming a tier row) is refused. The GL, AP and bankrecon surfaces, the staff roster, the index refresh preview and the unfiltered pricing lists are REFUSED, reads and writes alike. ADR 0007 section 5.5 names the stated limits and advises which scopes not to mint on a bound key.Fix round 3 (rounds 5 and 6, the principal's rulings)
Every changed entry, its old class in brackets:
POST /pos/till/{id}/close,GET /pos/till/{id}/report,GET /pos/till/{id}/zreport,GET /pos/zreports,GET /pos/till/currentnow holdtill_sessions.branch_id/till_z_reports.branch_idin the repository (WALLED with its column named);GET /pos/returns,GET /pos/returns/{id}holdpos_returns.branch_id;POST /pos/transactions/{id}/itemsandDELETE .../items/{itemId}hold the transaction's branch with the insert never happening on a foreign transaction.GET /accounts/{id},GET /accounts/{id}/transactions,GET /ar/customers/{id}/statement(the customer branch wall on the path customer); REFUSED [was WALLED]GET /ar/reconciliation(its ledger half carries no branch fact).GET /pricing/calculatenow also resolvesjob_idtoprojects.customer_id(the pin's customer with the foreign customer's job is refused);POST /tax/previewis confined through a namedship_to_id's customer.PUT/DELETE /admin/settings/ai,PUT/DELETE /admin/settings/routing,PUT /admin/modules/{id},POST /apps/{key}/enable,POST /apps/{key}/disable,PUT /matching/config,POST/PUT/DELETEandPOST .../import-catalogon/edi/partners,POST /vendors,POST/PUT /units,POST/PUT /pricing/categories,POST /pricing/rebates/programs,POST /millwork/options,POST/PUTandPOST .../transitionson/governance/rfcs,PUT /market-indices/{id}, the vehicle writes; the reads stay stated limits.POST /products,PATCH /products/{id}/margins,/dimensions,/lead-time,PUT /products/{id}/kit-components,PUT /products/{id}/units,PUT /products/{id}/pim/content, the two PIM deletes,PATCH .../pim/media/{mediaId}/primary, the fourPOST .../pim/generate/*,POST/PUT /charge-codes,POST/PUT /payment-terms, andPOST /purchase-orders/refresh-reorder-targets; their reads are STATED LIMIT [was WALLED] (GET /productsand its detail, kit components, PIM reads and unit sets,GET /charge-codes,GET /payment-terms, the product links, the POS product search and catalog).POST /pricing/rules, the category rule writes (single, bulk, update, delete, bulk delete) and the tax exemption writes on it are refused unless the pin holds the customer alone, the refusal naming the other branch; the reads keep the shared rule.user.branch.granted,user.branch.revokedanduser.home_branch.setaudit rows with the key as the actor. PD-6: the parsing upload and the vision scan stay stated limits and each call records itsai.parsing.upload/ai.vision.scanaudit row with the key that spent the credit.DealerWideReads, andPUT /locations/{id}writingpathis probed and pinned: the path is a materialized breadcrumb with no enforcement, the branch comes from the parent at insert, and no read resolves a branch from a path, so a path naming the foreign branch cannot move a row under it.ADR 0007 section 5.5, the CONTRACT-CHANGES row
SEC-BOUND-KEY-PIN-3and the delivery, charge codes, products and locations module pages say the same. The pos handler file is untouched (PR 72 rewrites it): the pos confinement lives in the repository queries and the branch wall layer; the pos files changed areinternal/pos/till_repository.go,internal/pos/returns_repository.go,internal/pos/repository.goandinternal/pos/service.go.Fix round 4 (head 7116cc5)
Class counts are unchanged over the 345 machine key reachable census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118. No route was added or removed. No pos file changed in this round (till_repository.go, returns_repository.go, repository.go and service.go under internal/pos are untouched). refactor/v1 (3c178d7) merged first with no conflicts.
Changed entries and walls added:
customer_id(wall.payments); intent names no customer and passeswall.invoiceswall.orders; and for every caller the ship to and contact must belong to the order's customer (400 naming the field)wall.quoteswall.draftGuard)Shared customer fields (deny by default)
A field not listed as BRANCH LOCAL is RESTRICTED. A bound key on a customer held by its branch and another is refused on every RESTRICTED field (403, the PD-4 words naming the lowest numbered other branch, a key.branch_refused row, nothing changed); a customer held by the pin alone, a user and an unshared customer are unchanged; an unbound key changes them with a
customer.shared_restricted_changedrow naming the key and the before and after values.No other route writes a customer's record (the repository writes are create, update, salesperson and escalation policy; the AR balance posting runs through the walled payment and invoice routes).
Tests
Each fix was red first on 372b37a (the legs answered 2xx or 204 and wrote rows) and is green now; each is also proven by a mutant (about 30 applied one at a time, all red). The twin sweep sends valid bodies with an own twin control, measures by the twins' ids, and a coverage test fails by name for any WALLED route without a bound key leg. Migration 107 has a test. Open for the lead: a machine key carries no role, so the ruling's "finance or owner" condition for the unbound key cannot be evaluated; the unbound key is allowed and audited.
Fix round 5 (reviews 10 and 11, principal ruling item 48)
Class counts are unchanged over the 345 machine key reachable census routes: WALLED 145, CONFINED 31, STATED LIMIT 51, REFUSED 118. No route was added or removed. refactor/v1 (d9ecb2b) was already the merge base: the round opened already up to date, no conflicts. PR 72 has not merged: its resolve route is not in the allowlist and its branch is not merged.
Every route and wall added or changed this round:
wall.pos, the resolver posSyncCustomersOf): a replayed sale on a customer the pin does not hold is 403 naming customer_id, nothing written (its ACCOUNT tender would invoice that customer); the change is in the wall layer and the allowlist note only, no pos code touchedbodyDocumentCustomerover documentCustomersOf, the resolution the pricing calculate read carries)quoteRowCustomerOf), as the draft promotions recheck their payload: a quote a caller moved onto a foreign customer is refused where it would become an orderThe customers:restricted scope (item 48)
A shared customer's RESTRICTED fields change only with the finance or owner role (a user) or an unbound machine key carrying the dedicated
customers:restrictedscope. The scope is in the grant grammar (ValidScopeGrammar) though no route requires it, so no module scope and no default grant carries it; a mint naming no scopes grants none. Only an owner may mint it: the admin role gets a 403 naming the scope, and a caller with no claims fails closed; the mint'skey.createdaudit row names every granted scope, that one included. A sales user, an admin user and every unbound key without the scope are refused in the PD-4 words naming the lowest numbered branch holding the customer; a bound key is refused even with the scope. Thecustomer.shared_restricted_changedaudit row stays exactly as it was, written by the scoped key's change with the actor and the before and after values; a user's change writes no row. The customer routes' user guard admits finance beside admin, owner and sales so the ruling's role can reach the fields. The escalation audit's before and after values now carry agreement_signed_at beside the reference.Shared customer fields (deny by default, round 5 state)
The field classes are unchanged from round 4 (the table below stands); what changed is who may change a RESTRICTED field of a shared customer (the scope rules above) and one new row:
Tests
Each code fix was red first on 4bad569 and is green now (the pos sync leg showed the sale synced and the foreign invoice written; the job legs showed the foreign job stored and priced by its override; the convert leg showed the order created with the foreign exemption; the default add leg showed the shared default cleared; the activity legs showed the foreign contact written; the scope legs showed the mint refused by the grammar and every caller change open; the legacy row's PUT showed the 500). Each test gap is proven by a mutant: the credit memo PUT pattern dropped from the invoices mount, salesperson_id classed BRANCH LOCAL, a DESC on either also held by lookup (the shared customer now carries a third branch), RecordRequest removed from wall.customers, policyFacts without the signature date, and a route driven only as an A control failing the coverage by name (the walled route coverage now counts only the drivers' explicit twin marks). Left for a later item: the pre existing draft order promote 500 (round 11 P3-3, identical on the base). The exposure gate and override fix and its coverage test are kept (item 48).
Fix round 6
Review 13 (second review) and the principal's item 54 rulings. No route added or removed (the same 345 census routes).
COALESCE(l.branch_id, r.branch_id)in the transaction create, the till open and the return lookup, so a bound key's pos sync on a register whose location names no branch is held to the pin (it wrote an open transaction at the default branch). The till session now carries the register's branch (branch_idin its body, four pos goldens re-recorded, listed in CONTRACT-CHANGES); the seed movesREG-01onto Kelowna so seeded amounts are unchanged.customer.shared_restricted_refusedaudit row (actor, fields, branch, method, path).admin,owner,sales.key.branch_refusedrow).customers:restricted.SEC-BOUND-KEY-PIN-6and the customers and CRM activities pages state every changed rule.