test(flows): extend the scheduled org-partition guard to update_record - #1373
Merged
Conversation
The guard walked every schedule-bound flow's create_record nodes and required
organization_id to be declared. It never inspected update_record at all, and
for an update that is not the right question anyway: the target row already
carries an organization. The question is where the WRITTEN VALUE came from.
Every scheduled sweep runs runAs:'system', and a system execution context is
the one context the driver's organization predicate does not constrain (the
platform's design, ADR-0049, unchanged here). test/saas-composition.test.ts
measures it on a real engine. So a sweep reads across every organization and
its writes are accepted against any of them, and an update_record can stamp
one tenant's value into another tenant's row with no NULL partition and no
index violation to catch it.
Extend the same walk. Each interpolation token in an update_record's
config.fields is resolved back to its source, transitively through assignment,
loop and get_record bindings, and must be organization-neutral: a literal, a
template function, a value taken off the swept row the node's own filter.id
names, or a fetch pinning organization_id to an already-proven source, which is
forecast_snapshot's {ownerAnyDeal.organization_id} precedent inverted and the
only proof mechanism admitted.
Anything else needs a written exemption, and the register is held from both
sides so it cannot rot: an unexplained write fails, and so does an exemption
that no longer matches. demo_bootstrap's rests on a machine-checked reading of
its absence from the multi-organization composition.
Part of #1363
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMzCeNC4SZcPNBpE2zCVCg
The register entry recorded the residual (a global sys_user identity lets one owner's deals span organizations, so the four accumulators can sum across them) but named nothing to follow. Reference #1372, where it is filed, so the note is traceable rather than terminal. Part of #1363 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WMzCeNC4SZcPNBpE2zCVCg
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
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.
Fixes #1363
Extends the scheduled-sweep organization guard from
create_recordtoupdate_record, in the same walk, through the platform's own region-slot map.The question an update needs
The existing half asks whether
organization_idis declared on a created row. Anupdate_recordtargets a row that already has an organization, so that question does not apply — and the card names the one that does: where did the written value come from?Every scheduled sweep runs
runAs: 'system', and a system execution context is the one context the driver's organization predicate does not constrain. That is the platform's design (ADR-0049) and nothing here asks it to change;test/saas-composition.test.tsalready measures it on a real engine. The consequence is that a sweep's reads span every organization and its writes are accepted against any of them, so anupdate_recordcan stamp one tenant's value into another tenant's row — no NULL partition, no index violation, nothing to catch it but a reviewer's eye.The rule
Each interpolation token in an
update_record'sconfig.fieldsis resolved back to its source and must be organization-neutral:{NOW()}/{TODAY()}— reads no row;filter.idnames — by construction already in that row's organization;organization_idto an already-proven source. This isforecast_snapshot's{ownerAnyDeal.organization_id}precedent inverted, and it is the only proof mechanism admitted — a second one is deliberately not invented.Provenance resolves transitively through
assignment,loopandget_recordbindings, because the interesting cases are never one hop:{firstUser.id}is an assignment off{userList.0}off aget_recordonsys_user. A spelling check sees an ordinary local variable; this names the whole chain.The general form turned out to be tractable, so the degraded "flag anything not literally the swept row" fallback was not used. The three binding constructs were measured rather than assumed — across
src/flows/the only binding keys areoutputVariable(38),iteratorVariable(12) andassignments(12) — and a fourth would leave its variable unbound, which the classifier reports as unproven. Conservative direction, and a loud one.Exemptions are argued and, where possible, measured
A flagged node is not automatically a defect, but it must be argued in
ORGANIZATION_NEUTRALITY_EXEMPTIONS. The register is held from both sides: an unexplained write fails, and so does an exemption that has stopped matching anything, so it cannot rot into claims about flows that have since changed. Staleness is checked per field, not per entry.demo_bootstrap(12 nodes,owner_id) — a real crossing, and the reason the flow is not registered in the multi-organization composition. The exemption is machine-checked: the test reads the flow's absence out of the SaaS composition, so re-registering it revokes the exemption automatically.forecast_snapshot(write_snapshot, 4 amount columns) — right in the single-organization shape it was written for, not proven in general, becausesys_useris a global identity. Filed separately as [finding]forecast_snapshotsums an owner's deals across every organization into one snapshot row #1372 rather than quietly fixed from here; this branch's surface is the guard.Proving it red — the part that makes "all green" mean something
"Added the rule, everything green" is indistinguishable from "the rule matches nothing", so both were measured, each with the mutation proven on disk and the restore proven by state (blob hash equal to the
HEADblob andgit diff HEADempty), never by exit code.Ablation 1 — empty the exemption register. Disk proof: register entries 1 → 0, marker absent → present. Result: red, printing the full inventory the register covers — 16 violations, 12 in
demo_bootstrapand 4 inforecast_snapshot, each with its chain:Restore proof:
NOW_BLOB == HEAD_BLOB,git diff HEADempty, marker occurrences 0.Ablation 2 — turn a real shipped sweep into a cross-row writer.
campaign_completiongained an independentsys_userfetch and wrote its id onto every campaign it touches —demo_bootstrap's defect shape, on shipped metadata reached through the barrel rather than through a fixture. Disk proof: injected text 0 → 2 occurrences, replaced text 1 → 0. Result: red.Restore proof:
NOW_BLOB == HEAD_BLOB,git diff HEADempty, injected text 0.Against vacuity, permanently. Two guards-of-the-guard ship with the rule: it asserts it locates a non-zero number of scheduled
update_recordnodes, and that it classifies a non-zero number of tokens with at least one real variable reference. A committed fixture sweep pins the red and the negative direction — the literal, the template function and the swept-row read in the same node must come back clean, so a rule that flagged everything fails here too. A last case pins the escape the rule offers authors: pin the source fetch to the target's organization and the same node clears, through the same code path.Verification
pnpm verifygreen at58fd2a3, the tip of this branch, run after the final commit: validate, typecheck, lint, i18n gate, source hygiene, token ratchet, build, and 147 test files / 3074 passed, 1 skipped.The diff is
test/flow-scheduled-org-partition.test.tsplus the changeset, and nothing undersrc/— checked rather than assumed, which is why the changeset carries empty frontmatter (the sanctioned "releases nothing" declaration). No platform behaviour was changed, and no in-app workaround was added.Generated by Claude Code
Generated by Claude Code