@synap-core/workspace-templates 0.9.4 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/grants.yaml CHANGED
@@ -43,6 +43,16 @@ dependencies:
43
43
  kind: workspace
44
44
  relation: require
45
45
  reason: "Provides the pod-wide client / partner / sponsor role vocabulary this workspace reuses."
46
+ # This template REFERENCES the pod-wide `grant-program` kind (the per-chain
47
+ # funding-program parent) that the blockchain-ecosystem overlay defines. The
48
+ # 3-layer grants spine is program → round → grant_submission: `round` (owned
49
+ # here) `belongs_to` a `grant-program` (owned there). `require` installs that
50
+ # overlay FIRST so `grant-program` exists (pod-wide) before this template
51
+ # provisions the edge + deadline view — same deps-first pattern as foundation.
52
+ - slug: blockchain-ecosystem
53
+ kind: workspace
54
+ relation: require
55
+ reason: "Provides the pod-wide grant-program kind (per-chain funding programs) this workspace's rounds belong to."
46
56
 
47
57
  workspace:
48
58
  name: Grants Pipeline
@@ -730,6 +740,14 @@ profiles:
730
740
  - healthy
731
741
  - at_risk
732
742
  - critical
743
+ - slug: driveLink
744
+ label: Drive folder
745
+ valueType: string
746
+ inputType: url
747
+ - slug: driveFolderId
748
+ label: Drive folder ID
749
+ valueType: string
750
+ inputType: text
733
751
  # ── Partner (shared ecosystem role — SSOT is foundation.yaml) ─────────────
734
752
  - slug: partner
735
753
  displayName: Partner
@@ -865,11 +883,26 @@ views:
865
883
  - averageAwardAmount
866
884
  sortBy: roundNumber
867
885
  sortDir: desc
886
+ # Round deadlines on one time axis. Scoped to `round` ONLY: rounds are the
887
+ # deadline SSOT (they carry `submissionDeadline`); `grant-program` carries
888
+ # `nextDeadline`, which the calendar's dateField fallback chain
889
+ # (dueDate/startDate/startTime/date) never resolves, so a program would land
890
+ # dateless in the "Unscheduled" backlog. Plotting programs + rounds on ONE
891
+ # timeline is a deferred UI-phase item. `colorField` (NOT `colorBy` — the
892
+ # calendar adapter reads `colorField`) colors each round by its status.
893
+ - name: Deadlines
894
+ type: calendar
895
+ scopeProfileSlug: round
896
+ description: Upcoming round submission deadlines, on one time axis.
897
+ config:
898
+ dateField: properties.submissionDeadline
899
+ colorField: status
900
+ # No `coverField`: grant-project has no image property (`summary` is prose, so it
901
+ # rendered as a text/blank cover). Fall back to the gallery's clean default cover.
868
902
  - name: Awarded Projects
869
903
  type: gallery
870
904
  scopeProfileSlug: grant-project
871
905
  config:
872
- coverField: summary
873
906
  showFields:
874
907
  - currentStatus
875
908
  - awardedRoundCount
@@ -899,6 +932,35 @@ entityLinks:
899
932
  targetProfileSlug: company
900
933
  type: submitted_by
901
934
  label: Submitted By
935
+ # The application belongs to the engagement that pursued it (contract lives in
936
+ # the composed operations base). Closes the spine on the submission side.
937
+ - sourceProfileSlug: grant_submission
938
+ targetProfileSlug: contract
939
+ type: for_contract
940
+ label: Engagement
941
+ # The commercial bridge: a grant application maps to its fee/commission `deal`,
942
+ # so a grant win is tied to the money. Mirrors networking.yaml's canonical
943
+ # `intro → billed_as → deal` commercial-mapping predicate (same edge type,
944
+ # reused for one consistent meaning across the pod graph). `deal` resolves here
945
+ # because it is a SYSTEM profile (validate.ts `SYSTEM_PROFILES`) — so this needs
946
+ # NO crm compose (grants stays an operations overlay, not a CRM). The deal lives
947
+ # in the CRM workspace; this is a cross-workspace relation, consistent with
948
+ # grant_submission's other cross-domain edges (submitted_by → company).
949
+ - sourceProfileSlug: grant_submission
950
+ targetProfileSlug: deal
951
+ type: billed_as
952
+ label: Billed as
953
+ # 3-layer spine: a round is one cycle OF a long-lived program (SCF #42 → SCF).
954
+ - sourceProfileSlug: round
955
+ targetProfileSlug: grant-program
956
+ type: belongs_to
957
+ label: Program
958
+ # The engagement pursues a program (contract lives in the composed operations
959
+ # base; grant-program in the required blockchain-ecosystem overlay).
960
+ - sourceProfileSlug: contract
961
+ targetProfileSlug: grant-program
962
+ type: pursues
963
+ label: Pursues
902
964
  - sourceProfileSlug: grant-project
903
965
  targetProfileSlug: round
904
966
  type: appears_in
@@ -915,6 +977,199 @@ entityLinks:
915
977
  targetProfileSlug: grant_submission
916
978
  type: evidenced_by
917
979
  label: Evidenced By
980
+
981
+ # The BASE grant playbook — ONE reusable, data-driven process that runs an
982
+ # engagement through ANY program. Chain-specifics (cadence, submissionTracks,
983
+ # trancheModel, nextDeadline) are READ from the `grantProgram` entity at runtime,
984
+ # NOT hardcoded per chain — so one playbook serves Stellar SCF, Optimism RetroPGF,
985
+ # etc. Only a genuinely different-SHAPED program later earns its own override
986
+ # playbook (base + data-driven-override decision). Generalizes the Stellar staged
987
+ # advisor (CP `stellar-grant-client`) into a template. Bound to `contract` (the
988
+ # engagement, per decision — NOT company); targets a `grant-program` param — the
989
+ # 3-layer spine (contract → grant-program → round → grant_submission) made runnable.
990
+ playbooks:
991
+ - name: Grant Process
992
+ description: Run an engagement through a grant program end to end — identify fit, draft the application, submit, track the decision, and deliver the tranches. Reads the program's own cadence / tracks / tranche model so one playbook fits every chain.
993
+ goalTemplate: >-
994
+ Advance this engagement ({contract}) through the grant program
995
+ ({grantProgram}) it is pursuing. FIRST read the program entity's own data —
996
+ its cadence, submissionTracks, trancheModel, and nextDeadline — plus the
997
+ client context on the contract, and let THOSE drive the plan: do not assume
998
+ a chain's mechanics, read them from the program. Then work the stages in
999
+ order and keep the client's `grant_submission` card as the durable state of
1000
+ record:
1001
+
1002
+ 1. Identify — understand the project and which of the program's
1003
+ submissionTracks fit; set the submission `stage` to `scope`.
1004
+ 2. Draft — write the abstract, then the full proposal (products & services,
1005
+ success criteria, GTM, traction); move `stage` `abstract` → `build` and
1006
+ fill the submission's body fields.
1007
+ 3. Submit — finalize against the program's nextDeadline and submit; once
1008
+ submitted, set `stage` to `awaiting-results`.
1009
+ 4. Awaiting — track the decision and keep `status` current
1010
+ (in-review / waiting-client / needs-document / blocked).
1011
+ 5. Awarded — on a decision, set `outcome` (awarded / rejected / partial)
1012
+ and record the awarded amount.
1013
+ 6. Delivering — for an award, work the program's trancheModel: fill the
1014
+ tranche deliverables and update trancheCompletion as each milestone ships.
1015
+
1016
+ Never fabricate — leave a field blank rather than guess. Every write is
1017
+ proposal-gated for the user to review.
1018
+ subjectProfile:
1019
+ profileSlug: contract
1020
+ params:
1021
+ - { name: contract, type: entity, profileSlug: contract, label: Engagement, required: true }
1022
+ - { name: grantProgram, type: entity, profileSlug: grant-program, label: Grant Program, required: true }
1023
+ # Generic stages, mapped onto grant_submission's own stage/status/outcome +
1024
+ # tranche vocab so the run can maintain the card as it advances.
1025
+ stages:
1026
+ - { key: identify, name: Identify, description: "Understand the project and which of the program's submission tracks fit — before any writing.", goal: "A clear project understanding and the fitting submission track; submission stage = scope." }
1027
+ - { key: draft, name: Draft, description: "Write the abstract, then the full proposal, from the client's real material.", goal: "Abstract then full proposal drafted; submission stage abstract → build with body fields filled." }
1028
+ - { key: submit, name: Submit, description: "Finalize against the program deadline and submit the application.", goal: "Application submitted on time; submission stage = awaiting-results." }
1029
+ - { key: awaiting, name: Awaiting, description: "Track the decision and keep the client status current.", goal: "Submission status reflects where the decision stands (in-review / waiting-client / needs-document / blocked)." }
1030
+ - { key: awarded, name: Awarded, description: "Record the funder's decision and the awarded amount.", goal: "Submission outcome set (awarded / rejected / partial) with the awarded amount." }
1031
+ - { key: delivering, name: Delivering, description: "Work the program's tranche model — ship and record each tranche.", goal: "Tranche deliverables filled and trancheCompletion updated as milestones ship." }
1032
+ executor: is-agent
1033
+ grants:
1034
+ - entity.create
1035
+ status: active
1036
+
1037
+ # Makes the spine ALIVE: a daily maintenance run that keeps each grant
1038
+ # application's `grant_submission` card current (advances stage + status) — the
1039
+ # durable, kanban-visible state of record. Generalizes the Stellar CP "Client
1040
+ # Sync" automation (read comms → assess → advance submission) into a chain-
1041
+ # AGNOSTIC workspace-template automation: it advances using grant_submission's
1042
+ # OWN stage/status vocab, so it works for any program (no Stellar-specific
1043
+ # assumptions). Same fan-out-safe shape the corpus uses (cron → in-flow `query`
1044
+ # → `loop`), NOT the `inputStrategy:'query'` playbook fan-out (which has the
1045
+ # known gap).
1046
+ automations:
1047
+ - key: grant-submission-sync
1048
+ name: Grant Submission Sync
1049
+ description: >-
1050
+ Once a day, keep each grant application's grant_submission card current —
1051
+ read the submission's recent activity, assess where the pursuit stands, and
1052
+ advance its stage + status so the pipeline stays the durable, kanban-visible
1053
+ state of record.
1054
+ triggerType: cron
1055
+ triggerConfig:
1056
+ expression: "0 8 * * *"
1057
+ # DRAFT by default (deliberate safety): this runs a paid AI assess per
1058
+ # grant_submission every day, so installing the suite must NOT silently start
1059
+ # paid runs. Flip to `active` to let the pipeline self-maintain — same
1060
+ # enabled-off-by-default convention as the radar / CRM-hygiene playbooks.
1061
+ status: draft
1062
+ metadata:
1063
+ templateKey: grant-submission-sync
1064
+ flow:
1065
+ nodes:
1066
+ - id: trigger
1067
+ type: trigger
1068
+ position: { x: 0, y: 0 }
1069
+ data:
1070
+ label: Daily at 08:00
1071
+ triggerType: cron
1072
+ config:
1073
+ expression: "0 8 * * *"
1074
+
1075
+ # READ-ONLY builtin + SELECTION. The grant_submission cards ARE the grant
1076
+ # pursuits (each is created by the `Grant Process` playbook at pursuit
1077
+ # start), so querying them directly is the safe selection: it touches ONLY
1078
+ # real pursuits and never fabricates a card for a non-grant contract.
1079
+ #
1080
+ # ── CREATE-IF-MISSING (deferred, flagged) ────────────────────────────
1081
+ # The Stellar reference does create-if-missing by querying its subject
1082
+ # (companies) and upserting the submission. Generalizing that safely means
1083
+ # selecting "contracts that PURSUE a grant program" — but `entity.query`
1084
+ # filters JSONB PROPERTIES only, it cannot filter by a `pursues` EDGE, and
1085
+ # there is no query-fan-out capability to enumerate edge-linked subjects.
1086
+ # Querying ALL contracts and creating a card per contract would spuriously
1087
+ # create submissions for every non-grant engagement. So creation stays the
1088
+ # `Grant Process` playbook's job (the agent creates the card when a pursuit
1089
+ # begins) and this automation MAINTAINS. Fully-generalized create-if-
1090
+ # missing needs an edge-aware selection capability that does not exist yet.
1091
+ - id: list-submissions
1092
+ type: capability
1093
+ position: { x: 0, y: 120 }
1094
+ data:
1095
+ label: List grant submissions to maintain
1096
+ verbId: entity.query
1097
+ inputMapping:
1098
+ profileSlug: grant_submission
1099
+ limit: "100"
1100
+
1101
+ - id: submissions-loop
1102
+ type: loop
1103
+ position: { x: 0, y: 240 }
1104
+ data:
1105
+ label: For each grant submission
1106
+ iteratorExpression: steps.list-submissions.output.entities
1107
+ itemVariable: submission
1108
+
1109
+ # READ-ONLY builtin. Recent activity on THIS submission's thread — the raw
1110
+ # signal the assess pass reads to place the pursuit's stage/status.
1111
+ - id: read-activity
1112
+ type: messages_query
1113
+ position: { x: 0, y: 360 }
1114
+ data:
1115
+ label: Read the submission's recent activity
1116
+ subjectEntityId: "{{loop.item.id}}"
1117
+ limit: 40
1118
+
1119
+ # READ-ONLY (pure compute). ONE assess pass → STRICT JSON naming the
1120
+ # submission's current stage + status, constrained to grant_submission's
1121
+ # OWN enum vocab so it maps straight back onto the card (chain-agnostic).
1122
+ - id: assess
1123
+ type: capability
1124
+ position: { x: 0, y: 480 }
1125
+ data:
1126
+ label: Assess the submission's stage + status → JSON
1127
+ verbId: ai.generate
1128
+ inputMapping:
1129
+ json: "true"
1130
+ maxTokens: "500"
1131
+ system: >-
1132
+ You are a strict data-extraction function for a grants pipeline.
1133
+ Read a grant application's recent activity and report where the
1134
+ application currently stands. Respond with STRICT JSON and NOTHING
1135
+ else. Never fabricate — if the activity does not clearly indicate a
1136
+ value, keep the submission's current one.
1137
+ prompt: >-
1138
+ The grant submission: {{loop.item.title}}
1139
+ Current stage: {{loop.item.properties.stage}}
1140
+ Current status: {{loop.item.properties.status}}
1141
+
1142
+ Recent activity (oldest first):
1143
+ {{steps.read-activity.output.messages}}
1144
+
1145
+ Report the submission's current state as STRICT JSON, using ONLY
1146
+ these vocabularies and NOTHING else:
1147
+ {
1148
+ "stage": "scope|abstract|build|awaiting-results",
1149
+ "status": "answered|waiting-client|needs-document|in-review|blocked"
1150
+ }
1151
+
1152
+ # GOVERNED WRITE via `output/entity_update` — advances the card's stage +
1153
+ # status (the durable, kanban-visible state). Governed like every output
1154
+ # write: routes to a proposal for review unless auto-approved.
1155
+ - id: advance-submission
1156
+ type: output
1157
+ position: { x: 0, y: 600 }
1158
+ data:
1159
+ label: Advance the submission stage + status
1160
+ outputType: entity_update
1161
+ config:
1162
+ entityId: "{{loop.item.id}}"
1163
+ properties:
1164
+ stage: "{{steps.assess.output.stage}}"
1165
+ status: "{{steps.assess.output.status}}"
1166
+ edges:
1167
+ - { id: gs-trigger-list, source: trigger, target: list-submissions }
1168
+ - { id: gs-list-loop, source: list-submissions, target: submissions-loop }
1169
+ - { id: gs-loop-read, source: submissions-loop, target: read-activity }
1170
+ - { id: gs-read-assess, source: read-activity, target: assess }
1171
+ - { id: gs-assess-advance, source: assess, target: advance-submission }
1172
+
918
1173
  app:
919
1174
  kind: stored
920
1175
  cellKey: entity-detail
package/src/life-os.yaml CHANGED
@@ -207,7 +207,12 @@ profiles:
207
207
  icon: User
208
208
  color: "#06B6D4"
209
209
  description: People in your intellectual and professional world.
210
- scope: WORKSPACE
210
+ # Pod-wide identity: `person` is ONE pod-wide SYSTEM kind, not a life-os-local
211
+ # copy. `entityScope: pod` lands the ROWS with `workspace_id = NULL` (visible
212
+ # from every workspace); `scope` omitted (no claim) so the applier reuses the
213
+ # system row and these life-os props land as this workspace's OVERLAY.
214
+ # (Was `scope: WORKSPACE`, which contradicted the pod-wide model.)
215
+ entityScope: pod
211
216
  properties:
212
217
  - slug: name
213
218
  label: Name
@@ -212,6 +212,11 @@ profiles:
212
212
  description: A company or person that provides a capability The Arch can broker a client need against.
213
213
  scope: shared
214
214
  profileKind: role
215
+ # Shared supply-role category — the intro-matcher's roster is now
216
+ # entity.query { roleCategory: "provider" }, spanning EVERY role tagged
217
+ # "provider" (this one in a public install; + blockchain-ecosystem's web3
218
+ # supply roles when The Arch installs both). One string, kept in sync there.
219
+ roleCategory: provider
215
220
  applicableKinds:
216
221
  - company
217
222
  - person
@@ -365,15 +370,21 @@ automations:
365
370
  limit: "100"
366
371
 
367
372
  # READ-ONLY builtin. The provider roster to match against — companies /
368
- # people wearing the pod-wide `solution-provider` role.
373
+ # people wearing ANY role tagged `roleCategory: "provider"`. Dynamic by
374
+ # design: a public install matches its `solution-provider`s; The Arch
375
+ # (which also installs blockchain-ecosystem) additionally matches the web3
376
+ # supply roles; any future supply role tagged "provider" auto-joins with
377
+ # ZERO edits here. No `profileSlug` → no per-role hardcoding, no dependency
378
+ # on blockchain-ecosystem (a public installer simply has no web3 roles to
379
+ # match, so nothing leaks).
369
380
  - id: list-providers
370
381
  type: capability
371
382
  position: { x: 0, y: 240 }
372
383
  data:
373
- label: List the solution-provider roster
384
+ label: List the provider roster (any roleCategory=provider)
374
385
  verbId: entity.query
375
386
  inputMapping:
376
- profileSlug: solution-provider
387
+ roleCategory: provider
377
388
  limit: "100"
378
389
 
379
390
  - id: needs-loop
@@ -212,6 +212,22 @@ entityLinks:
212
212
  targetProfileSlug: company
213
213
  type: briefing_about_client
214
214
  label: Client
215
+ # The CRM → Operations handoff edge. A `client` is a ROLE facet on a company
216
+ # OR a person (foundation's `applicableKinds`), so both endpoint pairs are
217
+ # declared — a solo-trader client is a person, and the pickup automation must
218
+ # be able to link its engagement either way.
219
+ - sourceProfileSlug: engagement
220
+ targetProfileSlug: company
221
+ type: engagement_for_client
222
+ label: Client
223
+ - sourceProfileSlug: engagement
224
+ targetProfileSlug: person
225
+ type: engagement_for_client
226
+ label: Client
227
+ - sourceProfileSlug: contract
228
+ targetProfileSlug: engagement
229
+ type: contract_for_engagement
230
+ label: Engagement
215
231
 
216
232
  # CURATED views — the delivery motion, not one per profile
217
233
  views:
@@ -571,3 +587,257 @@ automations:
571
587
  - { id: e-write-link, source: write-briefing, target: link-briefing }
572
588
  - { id: e-link-resolve, source: link-briefing, target: resolve-team }
573
589
  - { id: e-resolve-post, source: resolve-team, target: post-recap }
590
+
591
+ # ══════════════════════════════════════════════════════════════════════════
592
+ # CRM → Operations HANDOFF PICKUP
593
+ #
594
+ # The other half of business-developer's "Win and hand off". That flow ends by
595
+ # attaching the pod-wide `client` role with `handoffStatus`; nothing on the
596
+ # delivery side ever picked it up, so a won deal became a client and then
597
+ # nothing happened. This automation is that pickup: once a day it finds
598
+ # clients marked ready/handed_off that have NO engagement yet, and opens the
599
+ # delivery shell — a `proposed` engagement plus a `draft` contract.
600
+ #
601
+ # WHY THE SHARED `client` SLUG MATTERS: this reads foundation's pod-wide
602
+ # `client` role. When the CRM wrote a workspace-scoped `crm-client` instead,
603
+ # this query could not have seen a single handed-off party — the two halves of
604
+ # the handoff were spelling the same concept differently. Collapsing the fork
605
+ # is what makes this automation possible at all.
606
+ #
607
+ # WHY EVERY NODE TYPE IS WHAT IT IS (loop-body grammar, automation-executor.ts
608
+ # `LOOP_BODY_NODE_TYPES`): the per-client body may only contain
609
+ # command / output / condition / skill / capability / playbook_run /
610
+ # messages_query / query / fetch / transform. `switch`, `entity_read` and
611
+ # `related_entities` are BOUNDARY types — putting one in the chain would end
612
+ # the loop body there and silently run the rest ONCE for the whole run. That
613
+ # is why the two gates are `condition` nodes (per-item continue-semantics) and
614
+ # why the "does an engagement already exist?" lookup is a `capability`
615
+ # entity.query rather than a `related_entities` node.
616
+ #
617
+ # IDEMPOTENCY is per-client and does not rely on the gate alone:
618
+ # `dedupeBy: engagement-client` makes a second run for the same client a
619
+ # no-op SKIP before the governance gate is even reached, and the contract
620
+ # dedupes on `contract-engagement`. Both keys are real property defs on the
621
+ # profiles above, not invented ones.
622
+ #
623
+ # ── GOVERNANCE, HONESTLY ──────────────────────────────────────────────────
624
+ # `metadata.governance.forceProposeWrites` is deliberately NOT set here, and
625
+ # that is not an oversight. That marker is read in exactly ONE place —
626
+ # `deriveSessionForceProposeGovernance` (synap-backend
627
+ # `packages/api/src/utils/permission-check.ts`), off a FOCUS SESSION stamped
628
+ # by `executePlaybookRun`. It is a PLAYBOOK mechanism. A flow automation's
629
+ # `output/entity_create` goes through `checkAutomationWriteOrPropose`
630
+ # (packages/jobs/src/utils/automation-governance.ts), which never looks at
631
+ # automation metadata, so the field would be inert decoration. Worse: the
632
+ # automation propose path is currently BROKEN end-to-end (proposeAutomationWrite
633
+ # persists `data` FLAT while the approve executor reads a nested envelope —
634
+ # documented at automation-executor.ts, "KNOWN PRE-EXISTING BUG"), so forcing
635
+ # proposals here would produce reviews that throw on approve.
636
+ #
637
+ # The reviewability is carried by STATE instead: the engagement is created
638
+ # `proposed` and the contract `draft`. Nothing is active until a human moves
639
+ # it. If genuine proposal-gating is wanted, the canonical door is a PLAYBOOK
640
+ # with `metadata.governance.forceProposeWrites: true` + a `schedule` (the CRM
641
+ # Hygiene precedent in crm.yaml) — a different, agent-authored design.
642
+ # ══════════════════════════════════════════════════════════════════════════
643
+ - key: operations-crm-handoff-pickup
644
+ name: Pick up handed-off clients
645
+ description: >-
646
+ Daily. Finds pod-wide clients the CRM marked ready or handed off that have
647
+ no engagement yet, and opens the delivery shell for them — a proposed
648
+ engagement plus a draft contract, both linked to the client. Deduped per
649
+ client, so it is safe to re-run.
650
+ triggerType: cron
651
+ triggerConfig:
652
+ expression: "0 7 * * *"
653
+ status: active
654
+ metadata:
655
+ templateKey: operations-crm-handoff-pickup
656
+ # Same pod-wide dependency as the briefing automation: `client` entities
657
+ # are pod-wide, and `scope: pod` is the explicit opt-in that must be
658
+ # DEPLOYED for this query to return anything.
659
+ requiresPodWideEntityQueryLens: true
660
+ flow:
661
+ nodes:
662
+ - id: trigger
663
+ type: trigger
664
+ position: { x: 0, y: 0 }
665
+ data:
666
+ label: Daily at 07:00
667
+ triggerType: cron
668
+ config:
669
+ expression: "0 7 * * *"
670
+
671
+ # READ-ONLY builtin. Enumerates parties carrying the pod-wide `client`
672
+ # ROLE. `scope: pod` is required — see the long note on the briefing
673
+ # automation's `list-clients` node.
674
+ - id: list-clients
675
+ type: capability
676
+ position: { x: 0, y: 120 }
677
+ data:
678
+ label: List clients (pod-wide `client` role)
679
+ verbId: entity.query
680
+ inputMapping:
681
+ profileSlug: client
682
+ scope: pod
683
+ limit: "100"
684
+
685
+ - id: handoff-loop
686
+ type: loop
687
+ position: { x: 0, y: 240 }
688
+ data:
689
+ label: For each client
690
+ iteratorExpression: steps.list-clients.output.entities
691
+ itemVariable: client
692
+
693
+ # READ-ONLY builtin. `handoffStatus` lives on the client FACET row, not
694
+ # on the entity — `entity.query`'s `filter` reads `entities.properties`
695
+ # and therefore cannot see it. This is the only door that returns facet
696
+ # overlay properties.
697
+ - id: read-client-facets
698
+ type: capability
699
+ position: { x: 0, y: 360 }
700
+ data:
701
+ label: Read this party's role facets
702
+ verbId: entity_facet.list
703
+ inputMapping:
704
+ entityId: "{{loop.item.id}}"
705
+
706
+ # Pull the CLIENT facet's handoffStatus out BY SLUG. Positional
707
+ # indexing (`facets.0.properties…`) would be a latent bug: facet order
708
+ # is not guaranteed, so on a party that is also a partner it would read
709
+ # the wrong row. `filter` + `map` resolves it by identity.
710
+ - id: client-handoff-status
711
+ type: transform
712
+ position: { x: 0, y: 480 }
713
+ data:
714
+ label: Read the client facet's handoff status
715
+ expression: "{{steps.read-client-facets.output.facets}} | filter:item.profileSlug === client | map:{{item.properties.handoffStatus}}"
716
+
717
+ # PER-ITEM FILTER (continue-semantics). `pending` means the CRM has not
718
+ # finished with this client yet — only ready/handed_off are ours.
719
+ - id: handoff-gate
720
+ type: condition
721
+ position: { x: 0, y: 600 }
722
+ data:
723
+ label: Only clients the CRM marked ready or handed off
724
+ expression: "steps.client-handoff-status.output.result contains-any ready,handed_off"
725
+
726
+ # Pure read, workspace-lensed (engagements are Operations-local, so no
727
+ # `scope: pod`). `engagement-client` is a real property def above, so
728
+ # this equality filter hits `entities.properties`.
729
+ #
730
+ # `filter` is authored as a JSON *STRING*, not a mapping, and that is
731
+ # load-bearing: `parseQueryFilterConditions` (automation-executor.ts)
732
+ # template-resolves the filter ONLY when it is a string. Written as a
733
+ # YAML object, `{{loop.item.id}}` would reach SQL as that literal text,
734
+ # match nothing, and this gate would then open for EVERY client.
735
+ - id: existing-engagements
736
+ type: query
737
+ position: { x: 0, y: 720 }
738
+ data:
739
+ label: Does this client already have an engagement?
740
+ profileSlug: engagement
741
+ filter: '{"engagement-client": "{{loop.item.id}}"}'
742
+ limit: 1
743
+
744
+ - id: engagement-gate
745
+ type: condition
746
+ position: { x: 0, y: 840 }
747
+ data:
748
+ label: Only clients with no engagement yet
749
+ expression: "steps.existing-engagements.output.count === 0"
750
+
751
+ # Cron trigger carries no payload timestamp, so `now` falls back to the
752
+ # actual run time — stamping the handoff moment onto both records
753
+ # below so they plot on the calendar the day they're handed off.
754
+ # `engagement-end`/`start`-adjacent dates the owner still sets by hand
755
+ # stay unset.
756
+ - id: handoff-time
757
+ type: compute
758
+ position: { x: 0, y: 900 }
759
+ data:
760
+ label: Record handoff time
761
+ operation: now
762
+
763
+ # GOVERNED WRITE via an `output` node. Created `proposed`, never active:
764
+ # the delivery shell exists so the work is visible, but a human decides
765
+ # when it starts. `dedupeBy: engagement-client` is the durable
766
+ # per-client idempotency key — a re-run SKIPS before the governance gate.
767
+ - id: create-engagement
768
+ type: output
769
+ position: { x: 0, y: 960 }
770
+ data:
771
+ label: Open a proposed engagement
772
+ outputType: entity_create
773
+ config:
774
+ profileSlug: engagement
775
+ title: "Engagement — {{loop.item.title}}"
776
+ dedupeBy: engagement-client
777
+ properties:
778
+ engagement-status: proposed
779
+ engagement-client: "{{loop.item.id}}"
780
+ engagement-start: "{{steps.handoff-time.output.result}}"
781
+ engagement-notes: "Opened automatically from the CRM handoff. Confirm scope, dates, and owner before activating."
782
+
783
+ - id: link-engagement
784
+ type: output
785
+ position: { x: 0, y: 1080 }
786
+ data:
787
+ label: Link engagement → client
788
+ outputType: relation_create
789
+ config:
790
+ fromEntityId: "{{steps.create-engagement.output.entityId}}"
791
+ toEntityId: "{{loop.item.id}}"
792
+ relationType: engagement_for_client
793
+
794
+ # Draft contract, deduped on the engagement it governs. A skipped
795
+ # (duplicate) engagement create still returns the existing entityId, so
796
+ # this stays correctly anchored on a partial re-run.
797
+ - id: create-contract
798
+ type: output
799
+ position: { x: 0, y: 1200 }
800
+ data:
801
+ label: Open a draft contract
802
+ outputType: entity_create
803
+ config:
804
+ profileSlug: contract
805
+ title: "Contract — {{loop.item.title}}"
806
+ dedupeBy: contract-engagement
807
+ properties:
808
+ contract-status: draft
809
+ contract-engagement: "{{steps.create-engagement.output.entityId}}"
810
+ start-date: "{{steps.handoff-time.output.result}}"
811
+ contract-notes: "Drafted automatically from the CRM handoff. Attach the signed document and set the term."
812
+
813
+ - id: link-contract
814
+ type: output
815
+ position: { x: 0, y: 1320 }
816
+ data:
817
+ label: Link contract → engagement
818
+ outputType: relation_create
819
+ config:
820
+ fromEntityId: "{{steps.create-contract.output.entityId}}"
821
+ toEntityId: "{{steps.create-engagement.output.entityId}}"
822
+ relationType: contract_for_engagement
823
+ edges:
824
+ - { id: h-trigger-list, source: trigger, target: list-clients }
825
+ # `handoff-time` is a `compute` node, and `compute` is NOT a per-item
826
+ # loop-body type — the executor's body walk STOPS at it. Sitting inside
827
+ # the loop chain it severed the body, orphaning create-engagement /
828
+ # link-engagement / create-contract / link-contract into the main pass
829
+ # where `{{loop.item.id}}` resolved to undefined (empty-id entities and
830
+ # relations). It is a RUN-level timestamp, identical for every item, so
831
+ # it belongs before the loop; `{{steps.handoff-time.output.result}}`
832
+ # still resolves from inside the body because step outputs are global.
833
+ - { id: h-list-time, source: list-clients, target: handoff-time }
834
+ - { id: h-time-loop, source: handoff-time, target: handoff-loop }
835
+ - { id: h-loop-facets, source: handoff-loop, target: read-client-facets }
836
+ - { id: h-facets-status, source: read-client-facets, target: client-handoff-status }
837
+ - { id: h-status-gate, source: client-handoff-status, target: handoff-gate }
838
+ - { id: h-gate-existing, source: handoff-gate, target: existing-engagements }
839
+ - { id: h-existing-gate, source: existing-engagements, target: engagement-gate }
840
+ - { id: h-gate-engagement, source: engagement-gate, target: create-engagement }
841
+ - { id: h-engagement-link, source: create-engagement, target: link-engagement }
842
+ - { id: h-link-contract, source: link-engagement, target: create-contract }
843
+ - { id: h-contract-link, source: create-contract, target: link-contract }