Repository navigation
Two measurements contradict each other about whether the payment state machine is enforced on the seed path — settle it before anything else touches those columns #50
Description
The contradiction
Both readings are on main @ a7b7db5, @objectstack/* 17.4.0, on the pnpm demo seed path, about the same transition overdue → planned on clm_payment_plan:
| Run | What it measured |
|---|---|
| Card 09 (#39 / PR #42, merged) | The state machine refuses it: Payment instalment status cannot go from overdue to planned. Allowed from overdue: partial, paid. A re-seed reported 818 ok / 2 errors with two rows dropped. |
| Dogfood pass (#45) | Three re-seeds ran clean, 0 errors, and the run after payment_overdue had acted silently reverted 18 instalments — overdue 30 → 12 — with no refusal anywhere. |
These cannot both be true. ⛔ Neither is to be assumed correct because of who measured it or which came later.
Why this outranks the cosmetics in the same batch
If the seed path bypasses the hook, then overdue — a column card 09 declares the scheduled jobs to be the sole writer of — is silently writable by the fixture. That invariant is the load-bearing part of the reminder layer, and I accepted PR #42 as the recorded reviewer partly on the strength of card 09's refusal measurement. If that measurement does not hold on the path that matters, the acceptance rested on it wrongly and this card is how that gets found out.
The user-visible shape is also the worse of the two: a re-seed that undoes a day's job output with a clean banner. A loud refusal is recoverable; silent reversion means the demo database quietly stops reflecting reality and nobody learns.
What to establish — measurement only, ⛔ no fix in this card
- Reproduce both. Boot clean,
pnpm demo, triggerpayment_overdue, confirm rows moved tooverdueread from the database, thenpnpm demoagain and read those same rows again. Report the banner counts and the row states. ⛔ Do not trust the loader summary over the table —objectstack#17177is an open finding about exactly that summary lying. - Determine whether the hook runs at all on the seed write. The decisive question is not what the status ends up as but whether
payment_plan_state_machinewas invoked. Instrument or observe it directly. A seed writes under a system context; establish whether that context skips hooks, skips this hook, or runs it. - Explain the discrepancy. Three candidates, ⛔ none excluded yet:
- the hook genuinely does not run on the seed path (⇒ a real defect, and the more serious outcome);
- the two runs did not test the same rows (e.g. the dogfood's rows were not
overdueat the moment of re-seed); - one run read the wrong source (a log line instead of the table).
Say which, with the reading that settles it.⚠️ 「flake」 is not an answer here — the two results are deterministic claims about a state machine.
- If the answer is (a), ⛔ stop and report rather than fixing. Where the bypass lives decides everything: an app hook that needs a different registration is ours; a platform context that skips declared hooks is upstream and
AGENTS.mdsays report, never patch.
Declared rider: one unrelated one-line measurement, because it needs the same booted app
Does a name-spelled sys_user_position row resolve on 17.4.0?
POST /api/v1/data/sys_user_position {"user_id":"<a real user>","position":"clm_legal_counsel"}
POST /api/v1/security/explain (for that principal)
Report whether permissionSets gains the app's clm_legal set, or stays ["member_default"]. Context: #45 wrote ids into that field, got 201 with no diagnostic, and wrongly concluded the platform's position mechanism was broken — retracted on #45, with the evidence added to objectstack#16712. ⛔ Until this one line is measured, nobody may state that positions do or do not work on this version, and #46 cannot say which spelling to document.
This is a declared increment, not scope creep: it writes one runtime row in a local database, changes no repository file, and answers a question two open cards are waiting on.
Related
#39 / PR #42 (card 09 — the refusal measurement) · #45 (the dogfood pass — the silent-reversion measurement, and its retraction) · #41 (the seed-vs-jobs decision card, whose options cannot be judged until this is settled — its premise has already been marked unverified) · #46 (blocked on the rider) · objectstack#17177 · objectstack#16712
Activity
zhuangjianguo commentedon Sep 10, 2026
Claim: PM loop round 4 (seat
repo:hotclm). Dispatched by the PM seat for its dev; the dev inherits this claim and posts no second one.Session:⚠️ #47 touches the seed's
session_01R3n3GGzobdegM4HUzah1iRBranch:
claude/issue-50-seed-state-machine— created but expected to carry no commits; this is an investigation card. A commit only happens if the answer turns out to be a small app-side fix AND the seat authorises it after the report.Worktree:
hotclm-issue-50(dedicated, offorigin/main@a7b7db5)Domain:
repo:hotclm(single-lane repo — nodomain:*axis)File surface: ⛔ none by default. Read-only measurement plus one runtime row in a local gitignored database (the declared rider). ⛔ No repository file is edited without the seat saying so.
Container & model:
L,mode:subagent,model: default judgement tier— resolving two contradictory deterministic measurements is a judgement task, ⛔ not a mechanical one.Clause-②: no — a measurement writes nothing and changes no acceptance set or published surface.
Thread-read: none (no comments on this card at claim time, 2026-09-10T02:5xZ)
Serial constraints cleared: #47 (
src/data/**, dealinglegal_owner) and #49 (scripts/demo.mjs) are dispatched in the same batch. No conflict: this card edits nothing, and each sibling works in its own worktree off the sameorigin/main@a7b7db5, so neither can perturb this card's boot. One open PR, #40 (DESIGN.md, awaiting the maintainer's merge) — no overlap.legal_owner; this card measures the seed'sstatuswrites. Different columns, and this card readsorigin/main, ⛔ not #47's branch.Why this one went first in the batch: the other two cards in this round are presentation quality. This one asks whether an invariant the merged reminder layer rests on actually holds — and the seat accepted PR #42 as the recorded reviewer partly on the measurement this card may overturn. If the seed path bypasses the state machine, that acceptance rested on a reading that does not hold where it matters, and the seat would rather find that out from its own dispatch than from a customer.
⛔ The dev does not decide the outcome's destination. If the answer is "the hook does not run on the seed path", where the bypass lives (app registration vs platform context) decides whether this becomes an app fix or an upstream card, and that call is the seat's. Report and stop.
Generated by Claude Code
zhuangjianguo commentedon Sep 10, 2026
os-dev-report
{ "issue": 50, "status": "done", "branch": "claude/issue-50-seed-state-machine", "pr": null, "premise_still_valid": false, "summary": "Both measurements are correct and they are not in contradiction, so the card's premise (\"these cannot both be true\") is what my run falsifies. The answer is candidate (b): the two runs did not test the same rows. payment_plan_state_machine IS invoked on the seed write, under the seed's system context, with ctx.previous populated, and it refuses -- observed directly, not inferred: I drove the seed into two illegal transitions and both were refused by name in the loader's own log. Candidate (a) is excluded, there is NO bypass, and the invariant PR #42 was accepted on holds. On an untouched seeded corpus the job can only move the 18 'partial' rows (the arrears stage selects status in due/partial past planned_date; all 150 fixture 'planned' rows are dated +35 days or later, so the planned-to-due stage selects 0), and those 18 rows' fixture target is 'partial' -- overdue to partial is a DECLARED LEGAL EDGE (decision #10 → 2A). So the dogfood's 18 reverted rows with 0 refusals is the correct output of a working state machine, and only its implied inference (no refusal therefore not enforced) does not follow. Card 09's refusal came from rows it had itself mutated: its own report lists five probe rows created to reach windows the corpus leaves empty, one of them 'F11's planned→due hop'. A fixture-'planned' row back-dated so the job can take it planned→due→overdue is a row whose fixture target IS 'planned', and overdue to planned is refused. I recreated that probe and got card 09's message verbatim and its 818-ok/2-errors shape.", "tests": "Fresh worktree off origin/main a7b7db5, pnpm install, three sequential `node scripts/demo.mjs --port 3050` boots on one database (port 3050; the issue-47 dev's server on 3147 was left alone). Every row count below is read with better-sqlite3 opening .objectstack/data/objectstack.db readonly:true -- never from a loader summary or a banner -- and all 300 rows were snapshotted by id at each step so transitions are diffed per row, not by histogram. STATE A (clean demo): due 30, overdue 12, paid 90, partial 18, planned 150 = 300, exactly the fixture. STATE B (POST /api/v1/automation/payment_overdue/trigger, 200): flow summary selected 36, acted 18, skipped 18, failed 0 -- identical to the dogfood's line; node breakdown due_select selected 0, arrears_select selected 18, arrears_flag acted 18 (the 36 is 18 arrears_select plus 18 per-row parent get_record, NOT 36 instalments; the skipped 18 is the notify decision, nobody to tell). Table: overdue 30, partial 0. Row-id diff A to B: exactly 18 rows changed, ALL partial to overdue. STATE C (second demo = re-seed): table back to overdue 12, partial 18; row-id diff B to C is exactly the SAME 18 ids, ALL overdue to partial (set equality asserted). Loader's own line: inserted 0, updated 18, skipped 802, errored 120 -- the upsert SKIPS rows already equal to the fixture, so only those 18 of 820 rows were written at all, and all 120 errors are clm_contract.owner_id deferred-reference failures, zero state-machine refusals. THE DOGFOOD, REPRODUCED EXACTLY. HOOK-INVOCATION CONTROLS on the REST path: PATCH status overdue → 422 INVALID_STATE 'Only the daily payment job marks an instalment overdue...'; PATCH planned to paid → 422 'Payment instalment status cannot go from planned to paid. Allowed from planned: due.' So the hook is registered and an admin REST session is not isSystem. THE DECISIVE SEED-PATH PROBES (product paths only, no repository file and no direct DB write): PROBE A -- back-dated one fixture-'planned' row's planned_date to 2026-09-01 (status untouched, so the hook early-returns), then triggered the job: due_select selected 1, due_flag acted 1, then arrears_flag took it to overdue, i.e. both hops in one run, card 09's probe recreated. PROBE B -- moved one fixture-'partial' row partial to paid, a declared legal edge, giving a three-way disambiguator: hook runs with previous populated yields the 'paid is settled' message, hook runs with an EMPTY ctx.previous yields 'cannot go from planned to partial' (because from defaults to 'planned'), hook does not run yields silence. STATE E (third demo = re-seed with both probes in place): BOTH REFUSED. Loader: inserted 0, updated 17, skipped 801, errored 122 -- 120 owner_id plus exactly 2 payment_plan refusals; 820 minus 2 dropped = 818, card 09's number. Verbatim, record #275 (contract+seq uvFNYwUaD4_SaaoQ/4): hook 'payment_plan_state_machine' threw: Error: Payment instalment status cannot go from overdue to planned. Allowed from overdue: partial, paid. And record #96 (-TCovqmtjhx9uh9N/2): hook 'payment_plan_state_machine' threw: Error: A paid instalment is settled; its status cannot change to partial. Record a further instalment instead. Probe B naming 'paid' as the FROM state is the single reading that settles it: ctx.previous IS populated on a seed write. Direct evidence of invocation rather than inference: ERROR [BodyRunner] sandboxed hook threw with hook 'payment_plan_state_machine' and a stack of QuickJSScriptRunner.execute, boundBodyHandler, runWithTimeout, runWithRetry, runWithErrorPolicy, _ObjectQL.triggerHooks. Table: both refused rows HELD their prior state and the other 17 reverted legally. GATES: not applicable and not run -- no repository file was changed; git status is clean and git diff against BASE a7b7db5 is empty, so the branch carries no commits, as its claim anticipated.", "mcp_calls": "1 -- this report comment on #50. Every read (issues 39/41/45/46/50 and their comments) went through repo-scoped REST curl, zero MCP reads.", "rider_answer": "MEASURED, and the answer is YES: a name-spelled sys_user_position row DOES resolve on 17.4.0, so permissionSets GAINS the app's clm_legal set -- it does not stay at the baseline. POST /api/v1/data/sys_user_position {user_id: the Dev Admin id, position: 'clm_legal_counsel'} → 201. permissionSets went from [admin_full_access, organization_admin_no_bypass, member_default] to [clm_legal, clm_requester, admin_full_access, organization_admin_no_bypass, member_default], and positions gained clm_legal_counsel; explain attributes each as kind permission_set, name clm_legal, via 'direct grant'. It gains clm_requester too, because src/security/bind-position-sets.ts binds every position to that set as well. NEGATIVE CONTROL, exactly what #45 did: the same row with position 'position_mtv67s4q9ejl7r06' (the id) also returns 201 with NO diagnostic, the value is stored verbatim, positions carries the raw id string as if it were a name, it matches nothing, and permissionSets does NOT gain clm_legal. So #45's retraction was right and closed objectstack#13419 (resolution binds BY NAME) still holds on 17.4.0: the platform's position mechanism WORKS, and #46 should document the NAME spelling. One spelling note for #46: POST /api/v1/security/explain requires object and operation -- a body of only userId is 400 VALIDATION_FAILED. Both runtime rows were deleted afterwards; sys_user_position is back to 0 rows and no repository file was touched.", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: #41's premise is now settled and its options can be judged. A re-seed reverts ONLY rows whose fixture value differs, and on an untouched corpus that is exactly the 18 partial rows via the legal overdue-to-partial edge; the loud refusal card 09 saw needs a row the corpus does not contain. Successor: #41 (the seed-vs-jobs decision card), which reasons about precisely this behaviour.", "noted, not filed: a refused seed record is dropped WHOLE, not partially -- probe A's back-dated planned_date was left unwritten along with its status. One illegal status in a fixture row silently costs that row's other columns too. Successor: #41.", "noted, not filed: the human-facing seed banner is the unreliable surface, not the loader's counters. On boot 1 the inline seed blew its budget (WARN [Seeder] Inline seed exceeded 8000ms budget, continuing in background) and NO 'Seeds: app.objectstack.hotclm 820 rows' line was ever printed, while the seed was still writing for 3.5 minutes after 'Server is ready'. The authoritative line is the later WARN [Seeder] 'Seed loading completed' JSON. Successor: #41.", "noted, not filed, and a POSITIVE result for objectstack#17177: on this path the loader's counters AGREED with the table in all three boots -- updated 18 against 18 rows changed, and updated 17 / errored 122 against 17 changed with 2 rows holding. The summary did not lie about status here. Successor: objectstack#17177.", "noted, not filed: the hook deliberately does not consult the transition table on beforeInsert, so a FIRST seed onto an empty database legitimately inserts 12 rows straight into overdue. Intentional and correct for a fixture; recorded only so nobody reads it later as the bypass. Successor: none -- it is authored behaviour with the reason in the hook's own comment." ] }Generated by Claude Code
zhuangjianguo commentedon Sep 10, 2026
Review: ACCEPT — and the card's own premise is what got falsified. There is no bypass; the invariant PR #42 was accepted on holds.
premise_still_valid: falseis the correct verdict and this seat wrote the false premise. I asserted 「这两者不可能都对」. Both measurements were correct. No repository file was changed,git diffagainsta7b7db5is empty, and the branch carries no commits — exactly what an investigation card should look like.The resolution, and why it is candidate (b)
On an untouched seeded corpus
payment_overduecan only move the 18partialrows: the arrears stage selectsdue/partialpastplanned_date, and all 150 fixtureplannedrows are dated +35 days or later, so the planned→due stage selects 0. Those 18 rows' fixture target ispartial— so a re-seed writes them fromoverdueback topartial, andoverdue → partialis a declared legal edge (the maintainer's own ruling, #10 → 2A).⇒ The dogfood's "18 reverted, 0 refusals" is the correct output of a working state machine. Only the inference drawn from it — no refusal, therefore not enforced — does not follow.
Card 09's refusal came from rows it had itself created: its report lists five probe rows, one of them "F11's planned→due hop". A fixture-
plannedrow back-dated so the job takes it planned→due→overdue is a row whose fixture target isplanned, andoverdue → plannedis refused. That probe was recreated here and produced card 09's message verbatim and its818 ok / 2 errorsshape.Both runs measured a correct state machine on different rows. Nothing was wrong except my card.
Why this is the strongest evidence produced in this lane
partial → overdue. B→C: exactly the same 18 ids, alloverdue → partial, set equality asserted. A histogram would have shown the same totals and proved nothing.inserted 0, updated 18, skipped 802— so only 18 of 820 rows were written at all. That single reading is why "the re-seed reverted things" needed a per-row diff to interpret.ERROR [BodyRunner] sandboxed hook threw, hook named, stack throughQuickJSScriptRunner.execute … _ObjectQL.triggerHooks.partialrow topaid— a legal edge — separates three hypotheses at once: hook runs withpreviouspopulated ⇒ "a paid instalment is settled"; hook runs with an emptyctx.previous⇒ "cannot go from planned to partial" (becausefromdefaults toplanned); hook does not run ⇒ silence. The observed message was the first. That one reading establishes both that the hook runs and thatctx.previousis populated on a seed write — which is what a bypass claim would have had to deny.PATCH status → overduegives422 INVALID_STATE, so the hook is registered and an admin REST session is notisSystem.Rider: answered, and it unblocks two cards
A name-spelled
sys_user_positionrow DOES resolve on 17.4.0.permissionSetswent from the three baseline entries to includeclm_legalandclm_requester(the second becausebind-position-sets.tsbinds every position to that set as well), attributed byexplainaspermission_set/clm_legal/ direct grant.Negative control, reproducing #45 exactly: the id-spelled row also returns
201with no diagnostic, is stored verbatim, appears inpositionsas a raw id string as though it were a name, matches nothing, and does not gainclm_legal.⇒ #45's retraction was right, closed
objectstack#13419(resolution binds by name) still holds on 17.4.0, and the platform's position mechanism works. #46 documents the name spelling and is unblocked. Both runtime rows were deleted afterwards;sys_user_positionis back to 0.POST /api/v1/security/explainrequiresobjectandoperation— a body of onlyuserIdis400 VALIDATION_FAILED.Findings I am acting on
pnpm demostops being re-runnable once the daily jobs have run: the seed re-asserts statuses the state machines refuse to go back to #41 is now decidable, and my earlier premise-correction comment there needs revising: I called the silent revert 「更糟」. It is not a defect at all — it is a legal edge behaving correctly. Correcting that onpnpm demostops being re-runnable once the daily jobs have run: the seed re-asserts statuses the state machines refuse to go back to #41.planned_datewent unwritten along with its status. One illegal status in a fixture row silently costs that row's other columns too. That belongs inpnpm demostops being re-runnable once the daily jobs have run: the seed re-asserts statuses the state machines refuse to go back to #41's options — it raises the cost of the "let the seed keep asserting status" branch.Seeds: … 820 rowsline was ever printed, while the seed kept writing for 3.5 minutes afterServer is ready. The authoritative line is the laterWARN [Seeder] Seed loading completedJSON. This explainspnpm demoprints its "now create the three requesters" instruction ~290 lines and 120 ERROR lines before the ready banner, so nobody reads it #49's dev finding 0ERROR [SeedLoader]lines where the dogfood saw 120 — same script, different budget outcome. Carried into the review of PRpnpm demoprints its "now create the three requesters" instruction ~290 lines and 120 ERROR lines before the ready banner, so nobody reads it #51.objectstack#17177: on this path the loader's counters agreed with the table in all three boots. That card's "the summary lies" claim is path-specific, not universal — worth recording there rather than letting it read as a blanket indictment.beforeInsert, so a first seed onto an empty database legitimately inserts 12 rows straight intooverdue. Authored, with the reason at the site. ⛔ Recorded only so nobody later reads it as the bypass.Closing
The card's question is answered and its premise is retired. Closing as completed,
pm:*state label dropped in the same write. ⛔ No follow-up defect card is filed against the state machine, because there is no defect.Generated by Claude Code