Skip to content

Approval flows F1–F3 + the automation capability - #15

Merged
os-sam merged 2 commits into
mainfrom
claude/issue-6-approval-flows
Sep 7, 2026
Merged

Approval flows F1–F3 + the automation capability#15
os-sam merged 2 commits into
mainfrom
claude/issue-6-approval-flows

Conversation

@claude

@claude claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #6

What changed

Card 11 (DESIGN.md §05, F1–F3): three record_change flows under src/flows/, the barrel, and — in objectstack.config.ts — only the flows: key, its import, the requires: line and the comment above it (#2 and #5 are editing the same file; nothing else there was touched).

Flow Entry Approver(s) Approve Reject
employer_verification (F1) ats_employer.verification_status becomes pending step 1 position platform_ops, step 2 position platform_admin verified; inbox message to owner rejected; reviewer's comment copied to verification_note (platform-internal, FLS-hidden — nothing seeker-visible is written, per the card)
job_publish_review (F2) ats_job.status becomes pending_review position platform_ops published + published_at = now rejected + rejection_reason from the comment
offer_approval (F3) ats_offer.status becomes pending_approval the employer's admin members (see below) approved + approved_by draft

F2 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 run runAs: '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):

  • Reviewer comment. A decision resumes the run with decision + requestId only; the comment lives on sys_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.
  • F3's approver slate = the employer_admin members of THIS offer's employer, not every holder of the position. The slate is read from ats_employer_member by the offer's stamped employer scalar (no traversal), folded into a CSV of user ids with a loop + template interpolation, and handed to an expression approver over vars.*. Why that shape: the approver expression admits only current / trigger / vars roots (a comprehension variable is refused), and in 17.3.0 the assignment node 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.
  • F2's notifications iterate the member rows with a loop, each notify wrapped in a try_catch so one failed delivery does not end the run before the others are told (the lint asked for exactly this).
  • Entry is record-after-update gated 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 — defineStack refuses it before any rule runs:

✗ defineStack trigger capability validation failed (3 issues):
✗ flow 'employer_verification' declares a 'record_change' trigger but `requires` does not include 'triggers' — no 'record_change' trigger would be registered, so the flow would never auto-launch. Add requires: ['triggers'] …
(same for job_publish_review and offer_approval)

Beyond triggers, approvals is the only provider of the approval node executor (@objectstack/plugin-approvals), and without messaging a notify node 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 / queue are auto-prepended by the CLI for approvals. #7 (F4–F6) is serialized behind this PR because it edits the same line; it will likely want job explicitly.

Gates (final tree, b92d40c)

pnpm validate — exit 0, 3 Flows:

> objectstack validate


◆ Validate
────────────────────────────────────────
  → Loading configuration...
  Config: /home/user/ats-issue-6/objectstack.config.ts
  Load time: 108ms
  → Validating against ObjectStack Protocol...
  → Running author-time rules (42)...
  → Checking capability providers (#3366)...
  → Checking package docs (ADR-0046)...

  ✓ Validation passed (183ms)

  ATS v0.1.0
  Open-source recruiting marketplace — employers post, candidates apply, the platform governs.

  Data: 11 Objects  129 Fields
  UI: 0 Apps
  Logic: 3 Flows
  Security: 5 Positions  6 Permissions
  Runtime: 0 plugins

  ⚠ flow "employer_verification" · node "ops_review": every approver on this node routes to a group (position/team/department) whose members are runtime data — if none is staffed, the request resolves to an empty slate and waits forever, and (lockRecord) the record stays locked with no in-product recovery.
  ⚠ flow "employer_verification" · node "admin_review": every approver on this node routes to a group (position/team/department) whose members are runtime data — if none is staffed, the request resolves to an empty slate and waits forever, and (lockRecord) the record stays locked with no in-product recovery.
  ⚠ flow "job_publish_review" · node "ops_review": every approver on this node routes to a group (position/team/department) whose members are runtime data — if none is staffed, the request resolves to an empty slate and waits forever, and (lockRecord) the record stays locked with no in-product recovery.
  ⚠ No apps or plugins defined — this stack may not do much

pnpm lint — exit 0 (3 info-level suggestions, no warnings):

◆ Lint
────────────────────────────────────────
  → Loading configuration...
  ℹ Config: /home/user/ats-issue-6/objectstack.config.ts

  Suggestions (3)
  ℹ flow "employer_verification" · node "ops_review": every approver on this node routes to a group (position/team/department) whose members are runtime data … (see the suggestion text in the log)
    approval-approvers-may-resolve-empty  at flows[0].nodes[1].config.approvers
  ℹ flow "employer_verification" · node "admin_review": every approver on this node routes to a group (position/team/department) whose members are runtime data … (see the suggestion text in the log)
    approval-approvers-may-resolve-empty  at flows[0].nodes[2].config.approvers
  ℹ flow "job_publish_review" · node "ops_review": every approver on this node routes to a group (position/team/department) whose members are runtime data … (see the suggestion text in the log)
    approval-approvers-may-resolve-empty  at flows[1].nodes[2].config.approvers

  3 suggestion(s) (203ms)

pnpm typecheck — exit 0:


> ats@0.1.0 typecheck /home/user/ats-issue-6
> tsc --noEmit

The three suggestions are approval-approvers-may-resolve-empty on 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/main cannot write the F2/F3 fixtures — #10. The six stamp hooks call ctx.ql inside the lowered sandbox body, so every REST insert/update on ats_job, ats_offer, ats_application, ats_employer_member … answers 500. Not this card's surface; not touched here (src/hooks/ is byte-identical to origin/main in 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 holding platform_ops / platform_admin via sys_user_position rows in its org, and an ats_employer_member row with access_level: admin):

  • All three requests appear in the Console Approvals Inbox → My Pending (3): Offer Approval / Employer Administrator Approval, Job Publish Review / Platform Operations Review, Employer Verification / Platform Operations Review (Playwright screenshot taken; F3's pending_approvers resolved to the member's user id through the CSV fold).
  • F2 reject with comment → 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".
  • F3 approveapproved, approved_by = the approving admin's user id; inbox "Offer approved: …" to the submitter. F3 reject (second offer) → draft; inbox "Offer returned to draft: …".
  • F1 (no stamp hook on ats_employer, so this one also holds on a clean origin/main boot): ops approve → step 2 opens for platform_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.".
  • Every run ended 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:

F2 run: failed | Node 'publish_job' failed: update_record(ats_job) failed: hook 'ats_job_stamp' threw: TypeError: cannot read property 'find' of undefined
F3 run: failed | Node 'approve_offer' failed: update_record(ats_offer) failed: hook 'ats_offer_stamp' threw: TypeError: cannot read property 'find' of undefined

The decision is recorded, the run is stranded (RESUME_FAILED), and the record stays pending_review / pending_approval. So #10 blocks F2/F3 from working at all on origin/main, not just the demo; F1 is unaffected. Not fixed here.

Out of scope, handed back

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

…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
os-sam marked this pull request as ready for review September 7, 2026 01:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Approval flows F1–F3 + the automation capability

2 participants