Approval flows F1–F3 + the automation capability - #15
Merged
Conversation
…late Adds the three approval chains from DESIGN.md §05 as record_change flows and wires them into the stack: - employer_verification: pending → platform_ops → platform_admin → verified (+ inbox to the primary contact); reject at either step → rejected with the reviewer's comment on verification_note. - job_publish_review: pending_review → platform_ops → published with published_at, or rejected with rejection_reason from the comment; the employer's admin members are told either way. - offer_approval: pending_approval → the employer's admin members (resolved from ats_employer_member by the offer's employer scalar) → approved with approved_by, or back to draft; the submitter is told. Every write is a transition the object state machines already allow. Why `requires` gains four tokens rather than the one the card named: defineStack itself refuses a record_change flow unless `triggers` is declared (`flow 'x' declares a 'record_change' trigger but requires does not include 'triggers'`), `approvals` is the only provider of the `approval` node executor, and without `messaging` a notify node reports success while delivering nothing. All four providers ship with the CLI, so no package changes. The reviewer's comment is not on the resume envelope; each reject branch reads it back from sys_approval_action by request id. The admin slates are folded from member rows by template interpolation because the assignment node interpolates rather than evaluating CEL, and an approver expression admits only the current/trigger/vars roots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
os-sam
marked this pull request as ready for review
September 7, 2026 01:27
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 #6
What changed
Card 11 (DESIGN.md §05, F1–F3): three
record_changeflows undersrc/flows/, the barrel, and — inobjectstack.config.ts— only theflows:key, its import, therequires:line and the comment above it (#2 and #5 are editing the same file; nothing else there was touched).employer_verification(F1)ats_employer.verification_statusbecomespendingplatform_ops, step 2 positionplatform_adminverified; inbox message toownerrejected; reviewer's comment copied toverification_note(platform-internal, FLS-hidden — nothing seeker-visible is written, per the card)job_publish_review(F2)ats_job.statusbecomespending_reviewplatform_opspublished+published_at = nowrejected+rejection_reasonfrom the commentoffer_approval(F3)ats_offer.statusbecomespending_approvalapproved+approved_bydraftF2 tells the job's employer members with
access_level == 'admin'on both branches; F3 tells the submitter on both branches. Every write is a transition the object state machines already allow — none was widened. All three flows runrunAs: 'system': the post-decision writes are process outcomes, and attribution still carries the triggering user (#5494).Design points worth a reviewer's minute (each is also documented at the top of its flow file):
decision+requestIdonly; the comment lives onsys_approval_action. Each reject branch reads that row back by request id (get_record) before writing the note / reason. Verified: the copied text lands verbatim.employer_adminmembers of THIS offer's employer, not every holder of the position. The slate is read fromats_employer_memberby the offer's stampedemployerscalar (no traversal), folded into a CSV of user ids with aloop+ template interpolation, and handed to anexpressionapprover overvars.*. Why that shape: the approver expression admits onlycurrent/trigger/varsroots (a comprehension variable is refused), and in 17.3.0 theassignmentnode interpolates templates rather than evaluating CEL (measured: a CEL envelope there is stored as an object and the approver refuses it). The other reading —{ type: 'position', value: 'employer_admin' }— is exact only once every employer is its own organization (DESIGN.md §03) and is a cross-employer slate until then.onEmptyApprovers: 'admin_rescue'is declared explicitly.loop, eachnotifywrapped in atry_catchso one failed delivery does not end the run before the others are told (the lint asked for exactly this).record-after-updategated on the transition (record.x == "pending" && previous.x != "pending"), so unrelated edits do not re-open a review and the flows' own writes do not re-trigger them. Rows seeded directly in a pending state (Seed data: demo-en and demo-zh #5 seeds) therefore carry no request — same as the showcase, which launches those by hand.requires:— four tokens, not the one the card named (needs the maintainer's nod)The card authorises adding
'automation'and nothing else. That slate cannot pass the card's own gate —defineStackrefuses it before any rule runs:Beyond
triggers,approvalsis the only provider of theapprovalnode executor (@objectstack/plugin-approvals), and withoutmessaginganotifynode reports success while delivering nothing (skipped: true). So the PR declares['ui', 'automation', 'triggers', 'approvals', 'messaging']. No package changes: all four providers ship as CLI dependencies and the provider preflight is green.job/queueare auto-prepended by the CLI forapprovals. #7 (F4–F6) is serialized behind this PR because it edits the same line; it will likely wantjobexplicitly.Gates (final tree, b92d40c)
pnpm validate— exit 0, 3 Flows:pnpm lint— exit 0 (3 info-level suggestions, no warnings):pnpm typecheck— exit 0:The three suggestions are
approval-approvers-may-resolve-emptyon the position-routed nodes (F1 ×2, F2 ×1): a position with no holders leaves the request in admin rescue. Adding the suggested fallback (org_membership_level: owner) would change who may approve, which is the card's business, not mine — left as is.Verified in the running app (
pnpm dev)Caveat first:
origin/maincannot write the F2/F3 fixtures — #10. The six stamp hooks callctx.qlinside the lowered sandbox body, so every REST insert/update onats_job,ats_offer,ats_application,ats_employer_member… answers 500. Not this card's surface; not touched here (src/hooks/is byte-identical toorigin/mainin this PR — a local, uncommitted body-form stand-in was used only to drive the flows, then restored). Details in #10 and #14.Against that local stand-in, dev boot on 17.3.0 (
--fresh --database-driver memory, dev admin holdingplatform_ops/platform_adminviasys_user_positionrows in its org, and anats_employer_memberrow withaccess_level: admin):Offer Approval / Employer Administrator Approval,Job Publish Review / Platform Operations Review,Employer Verification / Platform Operations Review(Playwright screenshot taken; F3'spending_approversresolved to the member's user id through the CSV fold).ats_job.status = rejected,rejection_reason = "Salary range is missing and the description names a specific school."; inbox message "Job returned: Field Technician" to the admin member. F2 approve (second job) →published,published_at = 2026-09-07T01:11:10.502Z; inbox "Job published: Warehouse Lead".approved,approved_by= the approving admin's user id; inbox "Offer approved: …" to the submitter. F3 reject (second offer) →draft; inbox "Offer returned to draft: …".ats_employer, so this one also holds on a cleanorigin/mainboot): ops approve → step 2 opens forplatform_admin→ approve →verified,can_publish: true, inbox "Employer verified: Acme Robotics" to the owner. Reject at step 1 on a second employer →rejected,verification_note = "Registration number does not match the licence.".completed; 0 pending afterwards.Answer to the PM's question — do the flows' own writes die under #10 too? Yes, for F2 and F3. Staged the pending requests on a persistent sqlite file with a hook-free artifact, rebooted on the real artifact (six hooks registered) and granted the approvals:
The decision is recorded, the run is stranded (
RESUME_FAILED), and the record stayspending_review/pending_approval. So #10 blocks F2/F3 from working at all onorigin/main, not just the demo; F1 is unaffected. Not fixed here.Out of scope, handed back
ctx.qlis undefined) — every REST insert/update on the six stamped objects returns 500 #12 is closed as such) — stamp hooks vs the sandboxctx.ats_employer_memberhas noemployer_orgfield, yet the member stamp hook writes it and two row-level policies read it #14 —ats_employer_memberhas noemployer_orgfield, yet the member stamp writes it and two row-level policies read it; surfaces one step after the Stamp hooks throw under the body-only sandbox: ctx.ql is undefined, every write on ats_job / ats_application / ats_interview / ats_offer / ats_employer_member answers 500 #10 fix (INVALID_FIELD).sys_user_position.organization_id); under the org-per-employer posture the platform positions must be held in — or routed to — the right org. A tenancy-posture question for DESIGN.md §03, noted, not filed.No changeset: this repository has no changeset mechanism (release notes are written centrally at release time per AGENTS.md).
🤖 Generated with Claude Code
https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
Generated by Claude Code