Skip to content

feat(domain): align Hire lifecycle with ERC-8183 escrow states (open/funded/submitted/completed/rejected/expired) - #107

Merged
TOMOKI977 merged 2 commits into
mainfrom
feat/73-hire-erc8183-lifecycle
Oct 7, 2026
Merged

TOMOKI977 merged 2 commits into
mainfrom
feat/73-hire-erc8183-lifecycle

Conversation

@XxHugheadxX

@XxHugheadxX XxHugheadxX commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Closes #73

Summary

The Hire state machine in puls3_domain now mirrors the ERC-8183 escrow job 1:1 (ADR-0005 D6), so the tracker (#97) and the relay endpoints (#96) can apply each confirmed escrow transaction as one domain transition.

  • HireStatus: open, funded, submitted, completed, rejected, expired. The last three are terminal.

  • HireEvent: fund, submit, complete, reject, expire, one Hire method each:

    Method Transition Escrow call
    fund(payment, agentWallet:) open → funded fund. Same guards as the old pay: exact price, the agent wallet as provider, the consumer as payer
    submit() funded → submitted submit
    complete() submitted → completed complete, or the permissionless release after the approval window
    reject() open/funded/submitted → rejected reject. From open it is the cancel before paying
    expire() funded → expired claim_refund
    expire() open → expired None: the derived expiry of an unfunded job

    A submitted hire cannot expire (D4).

  • Removed states: inProgress, cancelled, failed and rated leave the lifecycle and become hire data, with the same field names as docs/architecture/models/hire.spy.yaml:

    • rejectedFrom: set by reject().
    • runtimeStatus (RuntimeStatus.queued/running/failed) and failureReason: fund sets queued, startRun() sets running, and failRun(reason:) sets failed with the reason. It changes only while the hire is funded, and the hire stays funded.
    • feedbackReference: set by recordFeedback(feedback, reference:), only on a completed hire and only once. The status does not change.
  • New typed errors:

    • InvalidRuntimeTransition
    • HireNotCompleted and HireAlreadyRated, named as in docs/architecture/api.md
  • Docs: docs/domain/hire-lifecycle.md has a new diagram, the transition table with the escrow call behind each transition, and the hire data rules. docs/domain/model.md has the updated glossary and class diagram, I18–I20 rewritten, and new I21 (runtime progress) and I22 (rejectedFrom).

Acceptance criteria

  • HireStatus has exactly open, funded, submitted, completed, rejected, expired
  • No inProgress, failed, cancelled or rated value remains in the domain lifecycle
  • Every transition matches an ERC-8183 transition (ADR-0005 D6 table); every other transition returns a typed error (InvalidHireTransition)
  • A rejected hire records the state it was rejected from (rejectedFrom)
  • A hire can hold an optional feedback reference without changing state (recordFeedback)
  • RuntimeStatus (queued, running, failed) exists separately from HireStatus
  • Terminal states accept no event
  • Domain docs show the same states and transitions as the code
  • CI is green (gate, domain, server, scan)

Verification evidence

$ cd puls3_domain && dart format --output=none --set-exit-if-changed .
Formatted 10 files (0 changed) in 0.02 seconds.
$ dart analyze --fatal-infos
No issues found!
$ dart test
00:00 +105: All tests passed!

$ grep -rnE "package:(serverpod|flutter|stellar)" puls3_domain/lib   # empty (ADR-0001)

$ cd ../puls3_server && dart analyze --fatal-infos   # the server depends on the domain
No issues found!

hire_lifecycle_test.dart checks the following:

  • the exact HireStatus, HireEvent and RuntimeStatus values;
  • one test per row of the transition table;
  • every other state × event pair, which must throw InvalidHireTransition;
  • the terminal states;
  • the fund guards;
  • rejectedFrom for each of the three source states;
  • the runtime progress rules;
  • the recordFeedback rules.

I broke each guard one at a time to check that the tests catch it. All 7 changes made the suite fail:

Removed or changed guard Failing tests
rejectedFrom recording 4
expire allowed from submitted 2
rated-once check 1
failRun allowed twice 1
fund without queued 4
payer check 1
feedback on a non-completed hire 1

To verify by hand, compare HireStatus, the table in hire_lifecycle_test.dart and docs/domain/hire-lifecycle.md with the D6 table in ADR-0005. Then search puls3_domain/lib for inProgress, cancelled, failed and rated used as hire states: there are none. failed appears only as RuntimeStatus.failed.

Notes for reviewers

HireStatus now mirrors the ERC-8183 job 1:1 (ADR-0005 D6): open, funded,
submitted, completed, rejected, expired. Events are fund, submit, complete
(complete or release), reject and expire (claim_refund, or the derived expiry
of an unfunded job). A submitted hire cannot expire (D4).

inProgress, cancelled, failed and rated leave the lifecycle and become hire
data: rejectedFrom (set by reject), RuntimeStatus queued/running/failed with
failureReason (startRun, failRun; only while funded), and feedbackReference
(recordFeedback; only on completed hires, once). New typed errors:
InvalidRuntimeTransition, HireNotCompleted, HireAlreadyRated.

Docs: hire-lifecycle.md and model.md updated to the same states and
transitions.

Closes #73
@XxHugheadxX XxHugheadxX added this to the Stellar Elite (Oct 10) milestone Oct 7, 2026
@XxHugheadxX XxHugheadxX added area: domain Business logic: agents, identity, reputation, payments type: feat New functionality P0 Blocks a deadline deliverable labels Oct 7, 2026
@XxHugheadxX XxHugheadxX self-assigned this Oct 7, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying puls3 with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6f85dcf
Status: ✅  Deploy successful!
Preview URL: https://f27f72cb.puls3-4lw.pages.dev
Branch Preview URL: https://feat-73-hire-erc8183-lifecyc.puls3-4lw.pages.dev

View logs

@TOMOKI977 TOMOKI977 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Really solid work: the transition table matches ADR-0005 D6 row for row, every other state × event pair is covered, the guards can't be bypassed (private constructor, no copyWith, final fields), and nothing outside puls3_domain uses the old API, so main won't break. The mutation check you did on the guards is a great habit.

Non-blocking notes:

  • submit() ignores runtimeStatus (entities.dart:194-197). A funded hire whose run failed can still be submitted, and a completed hire then carries runtimeStatus: failed and a failureReason. Either require a finished run (or no failure) before submit, or document in I21 what happens to the runtime fields after submit.
  • docs/blueprints/flows.md:169 still says "States follow the hire lifecycle (#10): Requested → Paid → InProgress → Delivered → Rated" (also S06 and the Rated status). A short "outdated, see hire-lifecycle.md" note would avoid confusion until #22/#61 updates the UI copy.
  • model.md:97 says each invariant has a test named after it, but the I21 and I22 groups (hire_lifecycle_test.dart:272,291) don't carry the prefix.
  • Feedback docs still say "a paid hire" (entities.dart:312, model.md:16) while the rule is now completed. Worth a line that HireAlreadyRated now means "already has a feedbackReference".

Heads-up for when the tracker (#97) and relay (#96) drive this entity (not introduced here, the same was true on main):

  • Replays. The tracker is at-least-once, so onFunded can run twice after a crash. Today a second fund throws the same InvalidHireTransition as a real conflict, so EscrowEffects can't tell "already applied" from "wrong state". Something like an idempotent no-op when status == funded && paymentTransaction == payment.tx (or a distinct error) would make that easy.
  • Rehydration. There is no public way to rebuild a funded/rejected/completed hire from storage without replaying transitions (which needs the Payment and wallet). The persistence adapter will need a restore constructor that keeps the invariants.

@TOMOKI977
TOMOKI977 merged commit 63fbad2 into main Oct 7, 2026
8 checks passed
@TOMOKI977

Copy link
Copy Markdown
Contributor

@XxHugheadxX merged into main as 63fbad2 (squash), which closes #73. Thanks, great work. The review notes above can go in a follow-up, and the replay/rehydration points matter once #96 and #97 start driving the entity. Heads-up for open branches: Hire.pay is now Hire.fund.

moises-cisneros added a commit that referenced this pull request Oct 7, 2026
Use Hire.fund and HireStatus.open/funded in the funding effect, the
Serverpod hire repository, the HireRepository port, tests and the
hire-payment spec after the lifecycle change in #107.
TOMOKI977 added a commit that referenced this pull request Oct 7, 2026
#93)

* feat(domain): verify escrow funding against the hire and add the HireRepository port

verifyFunding checks a job read from the escrow against the hire in the
order of api.md: state exactly Funded, client and evaluator equal to the
consumer, provider, agent, token, budget and the prepared expired_at. Each
rejection names the mismatching job field.

* feat(server): persist hires and payments in the Serverpod repository

hire_payment has unique indexes on hire_id, transaction_hash and job_id.
A unique violation of one of them maps to HirePaymentConflict by
constraint name; any other database error is rethrown. The migration is
generated on top of the chain submission migrations.

* feat(server): verify hire funding in the escrow effects of the chain tracker

HireEscrowEffects implements EscrowEffects.onFunded: it reads the job from
the escrow, checks it against the hire with verifyFunding and pays the hire
through Hire.pay, or fails with JobMismatch naming the job field or
JobEvidenceUnavailable. It reuses the shared JobFundedEvent parser and is
idempotent for the same submission. The tracker wiring uses it instead of
the no-op effects. HireService keeps createHire; the public payment
confirmation is gone because the tracker owns confirmation.

* docs(openspec): specify hire payment verification through the tracker effects

Also documents PULS3_HIRE_JOB_DURATION_SECONDS in the server README.

* test(server): cover unknown-constraint rethrow in the hire payment repository mapping

* fix(server): make fund submissions without a known hire a terminal JobMismatch

Document that details.field is job_id for replay, wrong-transaction and duplicate-job conflicts.

* chore(openspec): drop project config unrelated to hire payment

* refactor: align hire funding with the ERC-8183 hire lifecycle

Use Hire.fund and HireStatus.open/funded in the funding effect, the
Serverpod hire repository, the HireRepository port, tests and the
hire-payment spec after the lifecycle change in #107.

---------

Co-authored-by: Julio Cesar Severiche Orellana <39506930+TOMOKI977@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: domain Business logic: agents, identity, reputation, payments P0 Blocks a deadline deliverable type: feat New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: align Hire lifecycle with ERC-8183

2 participants