@synap-core/workspace-templates 0.9.3 → 0.10.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.
@@ -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 }
@@ -203,6 +203,202 @@ playbooks:
203
203
  - entity.create
204
204
  status: active
205
205
 
206
+ # ── LinkedIn conversation ingest (PURE-CONFIG automation — PROPOSAL) ─────────
207
+ # Pulls LinkedIn threads via Unipile and records each as an EXTERNAL channel +
208
+ # messages through the GENERIC `channel.ingest` verb. No provider code in the
209
+ # pod — this composes existing verbs (unipile_list_accounts /
210
+ # unipile_list_conversations / unipile_list_messages + channel.ingest).
211
+ #
212
+ # TRIGGER MODEL — ONE automation, both jobs (no high-freq polling):
213
+ # • RECONCILIATION: `triggerType: cron` at a LOW frequency (daily 06:00).
214
+ # • BACKFILL / on-demand: fire the SAME automation via
215
+ # `POST /automations/{id}/trigger` (MCP `synap_trigger_automation`), which
216
+ # "bypasses the automation's normal trigger config" and runs the flow once.
217
+ # channel.ingest is idempotent, so cron sweeps and manual backfills are safe to
218
+ # overlap and repeat.
219
+ #
220
+ # ⚠️ NEEDS-REVIEW — TWO verified engine constraints shape this flow; see the
221
+ # handoff report. (1) The automation engine has NO nested loops
222
+ # (LOOP_BODY_NODE_TYPES excludes `loop`/`sub_automation`), so the requested
223
+ # `loop conversations → loop messages → channel.ingest per message` graph is
224
+ # not expressible. This does ONE loop over conversations and hands each thread's
225
+ # whole `messages` array to channel.ingest in a single call. (2) A `transform`
226
+ # `map:` pipe can only project a single field/string per item, so it cannot
227
+ # normalize messages into `{text, sentAt, participant, idempotencySeed}` objects
228
+ # in-flow. => channel.ingest must accept a BATCH `messages: [...]` of raw
229
+ # provider rows and do the per-message normalization + idempotent dedup
230
+ # SERVER-SIDE. That is a contract change vs the single-message signature.
231
+ # Kept `status: draft` until that batch shape ships; then activate.
232
+ automations:
233
+ - key: linkedin-conversation-ingest
234
+ name: LinkedIn conversation ingest
235
+ description: >-
236
+ Pull LinkedIn conversations via Unipile and record each thread as an
237
+ external channel + its messages through channel.ingest (idempotent, so
238
+ re-runs are safe). Cron = low-frequency reconciliation; manual trigger =
239
+ on-demand backfill.
240
+ triggerType: cron
241
+ triggerConfig:
242
+ expression: "0 6 * * *"
243
+ status: draft
244
+ flow:
245
+ nodes:
246
+ - id: trigger
247
+ type: trigger
248
+ position: { x: 0, y: 0 }
249
+ data:
250
+ label: Daily 06:00 reconciliation (also fired manually for backfill)
251
+ triggerType: cron
252
+ config:
253
+ expression: "0 6 * * *"
254
+
255
+ # Resolve the connected account AT RUNTIME so the cron sweep needs no
256
+ # payload. A multi-account / multi-provider pod would still need a
257
+ # run-param accountId to sweep MORE than the first LinkedIn account — a
258
+ # per-account sweep would itself need the nested loop this engine lacks
259
+ # (secondary follow-up, noted in the report).
260
+ - id: list-accounts
261
+ type: capability
262
+ position: { x: 0, y: 120 }
263
+ data:
264
+ label: Resolve the connected Unipile account
265
+ verbId: unipile_list_accounts
266
+
267
+ # F5 — PROVIDER FILTER. `unipile_list_accounts` returns RAW Unipile v2
268
+ # account rows and a pod can connect several providers, so a non-LinkedIn
269
+ # account (WhatsApp / Telegram / Gmail) can sit at index 0. Taking a blind
270
+ # `accounts.0` would ingest that provider's threads AS linkedin. This
271
+ # `transform` node's array `filter:` pipe keeps ONLY LinkedIn accounts;
272
+ # every step below takes `.0` of the FILTERED list.
273
+ # Field: the predicate matches the RAW Unipile v2 account shape
274
+ # `type: 'LINKEDIN' | 'GMAIL' | 'WHATSAPP' | …` (verified in
275
+ # UnipileConnector's UnipileAccountSchema, NOT the capability's loose
276
+ # "provider" prose). evaluateCondition does an EXACT, case-sensitive
277
+ # `==`, hence the uppercase literal.
278
+ # Failure mode: if no LinkedIn account is connected (or the raw field
279
+ # shape ever drifts) the filtered list is empty → the accountId below
280
+ # resolves to "" → the run FAILS VISIBLY at list-conversations. It never
281
+ # silently ingests the wrong provider — that is the point of F5.
282
+ # Multi-LinkedIn-account: still takes the first; a run-param accountId
283
+ # is the multi-account follow-up (noted in the report).
284
+ - id: filter-linkedin-account
285
+ type: transform
286
+ position: { x: 0, y: 180 }
287
+ data:
288
+ label: Keep only LinkedIn Unipile accounts
289
+ expression: "{{steps.list-accounts.output.accounts}} | filter:item.type == 'LINKEDIN'"
290
+
291
+ - id: list-conversations
292
+ type: capability
293
+ position: { x: 0, y: 240 }
294
+ data:
295
+ label: List LinkedIn conversations for the account
296
+ verbId: unipile_list_conversations
297
+ inputMapping:
298
+ accountId: "{{steps.filter-linkedin-account.output.result.0.id}}"
299
+
300
+ # NOTE: the automation engine caps loops at MAX_LOOP_ITERATIONS=100 per run
301
+ # (logs a warning, then truncates). So one cron pass reconciles the 100
302
+ # most-recent conversations; accounts with >100 threads need either a
303
+ # newest-first list (so the active threads are always covered) or a paged
304
+ # sweep (blocked today — would need the nested loops the engine lacks).
305
+ # Fine for the current dogfood scale; revisit if a user exceeds ~100 threads.
306
+ - id: conversations-loop
307
+ type: loop
308
+ position: { x: 0, y: 360 }
309
+ data:
310
+ label: For each conversation
311
+ iteratorExpression: steps.list-conversations.output.conversations
312
+ itemVariable: conversation
313
+
314
+ # Loop body (owned by conversations-loop): list this thread's messages,
315
+ # then ingest. `{{loop.item.id}}` is the chat/thread id for THIS
316
+ # iteration; `steps.list-messages` is re-bound per iteration.
317
+ - id: list-messages
318
+ type: capability
319
+ position: { x: 0, y: 480 }
320
+ data:
321
+ label: List this thread's messages
322
+ verbId: unipile_list_messages
323
+ inputMapping:
324
+ threadId: "{{loop.item.id}}"
325
+ accountId: "{{steps.filter-linkedin-account.output.result.0.id}}"
326
+
327
+ # GENERIC ingest — records the external channel for this thread and its
328
+ # messages, idempotently (dedup on a per-message idempotency seed derived
329
+ # SERVER-SIDE, e.g. `${externalId}:${sentAt}:${body}`). See the ⚠️ note:
330
+ # `messages` is a BATCH of raw provider rows (pending channel.ingest's
331
+ # batch contract).
332
+ - id: ingest
333
+ type: capability
334
+ position: { x: 0, y: 600 }
335
+ data:
336
+ label: Ingest thread + messages (idempotent, generic)
337
+ verbId: channel.ingest
338
+ inputMapping:
339
+ provider: linkedin
340
+ externalId: "{{loop.item.id}}"
341
+ title: "{{loop.item.name}}"
342
+ accountExternalId: "{{steps.filter-linkedin-account.output.result.0.id}}"
343
+ # F1 — TARGET THE CRM WORKSPACE. Without this, channel.ingest lands
344
+ # in the automation's OWN workspace, but the dashboard inbox reads
345
+ # "The arch CRM". Single-tenant target = the inbox/CRM workspace.
346
+ # TODO(multi-tenant): make this an install/run param instead of a
347
+ # literal (the flow schema has no template-var/param mechanism for a
348
+ # capability inputMapping today, so a documented literal is the
349
+ # single-tenant path — see the report).
350
+ workspaceId: "499328b7-fd35-4143-b1bc-67b9e45d9b4b"
351
+ # F2 — SUPPRESS THE BACKFILL STORM. This is a poll / reconciliation
352
+ # of HISTORICAL messages, so it must NOT emit
353
+ # `external_message.received` per message (channel resolve + dedup
354
+ # insert still run). Without this, one backfill of N threads fans
355
+ # out through the webhook + automation-trigger reactors N×messages.
356
+ suppressSideEffects: true
357
+ # BATCH mode: hand the whole thread's messages[] to the generic verb
358
+ # in ONE call (the engine has no nested loops). channel.ingest stays
359
+ # provider-agnostic — messageMap tells it (in CONFIG) where each field
360
+ # lives inside a raw Unipile v2 message row. Verified live 2026-07-27:
361
+ # a message = { id, text, timestamp (ISO), sender_id, is_sender, ... }.
362
+ messages: "{{steps.list-messages.output.messages}}"
363
+ messageMap:
364
+ text: text
365
+ id: id
366
+ sentAt: timestamp
367
+ # F3 — DIRECTION. `is_sender` (dot-path to a boolean per row) marks
368
+ # the operator's OWN sent messages as OUTBOUND so the inbox renders
369
+ # them right-aligned, instead of everything looking inbound.
370
+ isOutbound: is_sender
371
+ # F6 — participantExternalId DROPPED on purpose. link-at-birth
372
+ # binds the channel to the participant of the first-processed
373
+ # (newest) message, which — when that message is the account
374
+ # OWNER's own reply — mis-binds the whole thread to yourself. The
375
+ # channel `title` already carries the conversation (contact) name,
376
+ # so dropping the per-message sender id lets link-at-birth avoid
377
+ # the mis-bind.
378
+ edges:
379
+ - { id: e-trigger-accounts, source: trigger, target: list-accounts }
380
+ - {
381
+ id: e-accounts-filter,
382
+ source: list-accounts,
383
+ target: filter-linkedin-account,
384
+ }
385
+ - {
386
+ id: e-filter-convos,
387
+ source: filter-linkedin-account,
388
+ target: list-conversations,
389
+ }
390
+ - {
391
+ id: e-convos-loop,
392
+ source: list-conversations,
393
+ target: conversations-loop,
394
+ }
395
+ - {
396
+ id: e-loop-messages,
397
+ source: conversations-loop,
398
+ target: list-messages,
399
+ }
400
+ - { id: e-messages-ingest, source: list-messages, target: ingest }
401
+
206
402
  # LinkedIn send/search via Unipile, email send via Gmail (nango-google). The
207
403
  # user connects each once on install; a missing key fails gracefully per-skill.
208
404
  integrations:
@@ -268,7 +268,12 @@ profiles:
268
268
  icon: user
269
269
  color: "#0891B2"
270
270
  description: "Team member or stakeholder"
271
- scope: WORKSPACE
271
+ # Pod-wide identity: `person` is ONE pod-wide SYSTEM kind. `entityScope: pod`
272
+ # lands the ROWS with `workspace_id = NULL` (visible from every workspace);
273
+ # `scope` omitted (no claim) so the applier reuses the system row and these
274
+ # project-management props land as this workspace's OVERLAY.
275
+ # (Was `scope: WORKSPACE`, which contradicted the pod-wide model.)
276
+ entityScope: pod
272
277
  properties:
273
278
  - slug: person-role
274
279
  label: Role