@synap-core/workspace-templates 0.10.0 → 0.12.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.
@@ -528,6 +528,68 @@ profiles:
528
528
  valueType: string
529
529
  inputType: textarea
530
530
 
531
+ # ── Roles the BUSINESS MODEL needs, and why they live here ─────────────────
532
+ # `porteur` and `funder` are hats, not kinds: the same person entity is a
533
+ # porteur of one venture and a funder of another, and the hat is revocable
534
+ # while the person is not. They are declared HERE because foundation.yaml is
535
+ # the shared-role SSOT (`shared-role-ssot.test.ts` derives the guarded
536
+ # vocabulary from this file) — declaring them in business-model.yaml instead
537
+ # would fork a second identity for a hat the whole pod refers to.
538
+ - slug: porteur
539
+ displayName: Porteur
540
+ icon: user-check
541
+ color: amber
542
+ description: The person carrying a venture — the one whose commitment, skills, and constraints the model actually rests on. Distinct from `client` (who pays) and `partner` (who collaborates).
543
+ scope: shared
544
+ profileKind: role
545
+ applicableKinds:
546
+ - person
547
+ properties:
548
+ - slug: brings
549
+ label: What They Bring
550
+ valueType: string
551
+ inputType: textarea
552
+ description: The specific competence, network, capital, or legitimacy this person contributes that the model depends on.
553
+ - slug: commitmentLevel
554
+ label: Commitment
555
+ valueType: string
556
+ inputType: select
557
+ enumValues: [full-time, part-time, advisory, intermittent]
558
+ - slug: constraints
559
+ label: Constraints
560
+ valueType: string
561
+ inputType: textarea
562
+ description: What limits this porteur — time, cash runway, geography, non-compete, family. A model that ignores these is fiction.
563
+ - slug: funder
564
+ displayName: Funder
565
+ icon: banknote
566
+ color: green
567
+ description: A source of non-revenue money or subsidy — a bank, a public scheme, an investor, a grantor. A hat worn by a company or a person, never a kind of its own.
568
+ scope: shared
569
+ profileKind: role
570
+ applicableKinds:
571
+ - company
572
+ - person
573
+ properties:
574
+ - slug: fundingType
575
+ label: Funding Type
576
+ valueType: string
577
+ inputType: select
578
+ enumValues: [grant, subsidy, loan, equity, guarantee, prize, other]
579
+ - slug: amountCommitted
580
+ label: Amount Committed
581
+ valueType: number
582
+ inputType: number
583
+ - slug: eligibilityConditions
584
+ label: Eligibility Conditions
585
+ valueType: string
586
+ inputType: textarea
587
+ description: The conditions the venture must satisfy to keep this money. These are the constraints most often discovered too late.
588
+ - slug: decisionDate
589
+ label: Decision Date
590
+ valueType: date
591
+ inputType: date
592
+
531
593
  views:
532
594
  - name: Mission & Vision
533
595
  slug: mission-vision
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
@@ -873,11 +883,26 @@ views:
873
883
  - averageAwardAmount
874
884
  sortBy: roundNumber
875
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.
876
902
  - name: Awarded Projects
877
903
  type: gallery
878
904
  scopeProfileSlug: grant-project
879
905
  config:
880
- coverField: summary
881
906
  showFields:
882
907
  - currentStatus
883
908
  - awardedRoundCount
@@ -907,6 +932,35 @@ entityLinks:
907
932
  targetProfileSlug: company
908
933
  type: submitted_by
909
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
910
964
  - sourceProfileSlug: grant-project
911
965
  targetProfileSlug: round
912
966
  type: appears_in
@@ -923,6 +977,210 @@ entityLinks:
923
977
  targetProfileSlug: grant_submission
924
978
  type: evidenced_by
925
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
+ # A grant pursuit is an ENGAGEMENT with a before/during/after lifecycle that
1024
+ # outlives any one working session (drafting, then weeks of waiting on the
1025
+ # funder, then tranche delivery), so this is a PROJECT template: its stages
1026
+ # coordinate a long-running container rather than script one focus session.
1027
+ scope: project
1028
+ # Generic stages, mapped onto grant_submission's own stage/status/outcome +
1029
+ # tranche vocab so the run can maintain the card as it advances.
1030
+ # `category` is the closed rollup bucket (backlog|planned|started|paused|
1031
+ # completed|canceled), chosen by MEANING: `identify` is pre-commitment
1032
+ # scoping (planned); `awaiting` is genuinely blocked on the funder (paused);
1033
+ # `awarded` stays `started` because delivery follows an award — a rejected
1034
+ # pursuit's terminality lives on the submission's own `outcome` field, not
1035
+ # here.
1036
+ stages:
1037
+ - { key: identify, name: Identify, category: planned, 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." }
1038
+ - { key: draft, name: Draft, category: started, 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." }
1039
+ - { key: submit, name: Submit, category: started, description: "Finalize against the program deadline and submit the application.", goal: "Application submitted on time; submission stage = awaiting-results." }
1040
+ - { key: awaiting, name: Awaiting, category: paused, 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)." }
1041
+ - { key: awarded, name: Awarded, category: started, description: "Record the funder's decision and the awarded amount.", goal: "Submission outcome set (awarded / rejected / partial) with the awarded amount." }
1042
+ - { key: delivering, name: Delivering, category: started, description: "Work the program's tranche model — ship and record each tranche.", goal: "Tranche deliverables filled and trancheCompletion updated as milestones ship." }
1043
+ executor: is-agent
1044
+ grants:
1045
+ - entity.create
1046
+ status: active
1047
+
1048
+ # Makes the spine ALIVE: a daily maintenance run that keeps each grant
1049
+ # application's `grant_submission` card current (advances stage + status) — the
1050
+ # durable, kanban-visible state of record. Generalizes the Stellar CP "Client
1051
+ # Sync" automation (read comms → assess → advance submission) into a chain-
1052
+ # AGNOSTIC workspace-template automation: it advances using grant_submission's
1053
+ # OWN stage/status vocab, so it works for any program (no Stellar-specific
1054
+ # assumptions). Same fan-out-safe shape the corpus uses (cron → in-flow `query`
1055
+ # → `loop`), NOT the `inputStrategy:'query'` playbook fan-out (which has the
1056
+ # known gap).
1057
+ automations:
1058
+ - key: grant-submission-sync
1059
+ name: Grant Submission Sync
1060
+ description: >-
1061
+ Once a day, keep each grant application's grant_submission card current —
1062
+ read the submission's recent activity, assess where the pursuit stands, and
1063
+ advance its stage + status so the pipeline stays the durable, kanban-visible
1064
+ state of record.
1065
+ triggerType: cron
1066
+ triggerConfig:
1067
+ expression: "0 8 * * *"
1068
+ # DRAFT by default (deliberate safety): this runs a paid AI assess per
1069
+ # grant_submission every day, so installing the suite must NOT silently start
1070
+ # paid runs. Flip to `active` to let the pipeline self-maintain — same
1071
+ # enabled-off-by-default convention as the radar / CRM-hygiene playbooks.
1072
+ status: draft
1073
+ metadata:
1074
+ templateKey: grant-submission-sync
1075
+ flow:
1076
+ nodes:
1077
+ - id: trigger
1078
+ type: trigger
1079
+ position: { x: 0, y: 0 }
1080
+ data:
1081
+ label: Daily at 08:00
1082
+ triggerType: cron
1083
+ config:
1084
+ expression: "0 8 * * *"
1085
+
1086
+ # READ-ONLY builtin + SELECTION. The grant_submission cards ARE the grant
1087
+ # pursuits (each is created by the `Grant Process` playbook at pursuit
1088
+ # start), so querying them directly is the safe selection: it touches ONLY
1089
+ # real pursuits and never fabricates a card for a non-grant contract.
1090
+ #
1091
+ # ── CREATE-IF-MISSING (deferred, flagged) ────────────────────────────
1092
+ # The Stellar reference does create-if-missing by querying its subject
1093
+ # (companies) and upserting the submission. Generalizing that safely means
1094
+ # selecting "contracts that PURSUE a grant program" — but `entity.query`
1095
+ # filters JSONB PROPERTIES only, it cannot filter by a `pursues` EDGE, and
1096
+ # there is no query-fan-out capability to enumerate edge-linked subjects.
1097
+ # Querying ALL contracts and creating a card per contract would spuriously
1098
+ # create submissions for every non-grant engagement. So creation stays the
1099
+ # `Grant Process` playbook's job (the agent creates the card when a pursuit
1100
+ # begins) and this automation MAINTAINS. Fully-generalized create-if-
1101
+ # missing needs an edge-aware selection capability that does not exist yet.
1102
+ - id: list-submissions
1103
+ type: capability
1104
+ position: { x: 0, y: 120 }
1105
+ data:
1106
+ label: List grant submissions to maintain
1107
+ verbId: entity.query
1108
+ inputMapping:
1109
+ profileSlug: grant_submission
1110
+ limit: "100"
1111
+
1112
+ - id: submissions-loop
1113
+ type: loop
1114
+ position: { x: 0, y: 240 }
1115
+ data:
1116
+ label: For each grant submission
1117
+ iteratorExpression: steps.list-submissions.output.entities
1118
+ itemVariable: submission
1119
+
1120
+ # READ-ONLY builtin. Recent activity on THIS submission's thread — the raw
1121
+ # signal the assess pass reads to place the pursuit's stage/status.
1122
+ - id: read-activity
1123
+ type: messages_query
1124
+ position: { x: 0, y: 360 }
1125
+ data:
1126
+ label: Read the submission's recent activity
1127
+ subjectEntityId: "{{loop.item.id}}"
1128
+ limit: 40
1129
+
1130
+ # READ-ONLY (pure compute). ONE assess pass → STRICT JSON naming the
1131
+ # submission's current stage + status, constrained to grant_submission's
1132
+ # OWN enum vocab so it maps straight back onto the card (chain-agnostic).
1133
+ - id: assess
1134
+ type: capability
1135
+ position: { x: 0, y: 480 }
1136
+ data:
1137
+ label: Assess the submission's stage + status → JSON
1138
+ verbId: ai.generate
1139
+ inputMapping:
1140
+ json: "true"
1141
+ maxTokens: "500"
1142
+ system: >-
1143
+ You are a strict data-extraction function for a grants pipeline.
1144
+ Read a grant application's recent activity and report where the
1145
+ application currently stands. Respond with STRICT JSON and NOTHING
1146
+ else. Never fabricate — if the activity does not clearly indicate a
1147
+ value, keep the submission's current one.
1148
+ prompt: >-
1149
+ The grant submission: {{loop.item.title}}
1150
+ Current stage: {{loop.item.properties.stage}}
1151
+ Current status: {{loop.item.properties.status}}
1152
+
1153
+ Recent activity (oldest first):
1154
+ {{steps.read-activity.output.messages}}
1155
+
1156
+ Report the submission's current state as STRICT JSON, using ONLY
1157
+ these vocabularies and NOTHING else:
1158
+ {
1159
+ "stage": "scope|abstract|build|awaiting-results",
1160
+ "status": "answered|waiting-client|needs-document|in-review|blocked"
1161
+ }
1162
+
1163
+ # GOVERNED WRITE via `output/entity_update` — advances the card's stage +
1164
+ # status (the durable, kanban-visible state). Governed like every output
1165
+ # write: routes to a proposal for review unless auto-approved.
1166
+ - id: advance-submission
1167
+ type: output
1168
+ position: { x: 0, y: 600 }
1169
+ data:
1170
+ label: Advance the submission stage + status
1171
+ outputType: entity_update
1172
+ config:
1173
+ entityId: "{{loop.item.id}}"
1174
+ properties:
1175
+ stage: "{{steps.assess.output.stage}}"
1176
+ status: "{{steps.assess.output.status}}"
1177
+ edges:
1178
+ - { id: gs-trigger-list, source: trigger, target: list-submissions }
1179
+ - { id: gs-list-loop, source: list-submissions, target: submissions-loop }
1180
+ - { id: gs-loop-read, source: submissions-loop, target: read-activity }
1181
+ - { id: gs-read-assess, source: read-activity, target: assess }
1182
+ - { id: gs-assess-advance, source: assess, target: advance-submission }
1183
+
926
1184
  app:
927
1185
  kind: stored
928
1186
  cellKey: entity-detail
package/src/life-os.yaml CHANGED
@@ -731,6 +731,16 @@ profiles:
731
731
  inputType: text
732
732
 
733
733
  views:
734
+ # Knowledge is the pod-owned, validated-memory kind. Notes remain the
735
+ # workspace's capture inbox; this view deliberately reuses the system
736
+ # contract instead of recreating a competing knowledge schema here.
737
+ - name: Knowledge
738
+ slug: knowledge
739
+ type: table
740
+ scopeProfileSlug: knowledge
741
+ description: Validated insights and cautions, with their long-form context and evidence kept in the graph.
742
+ config:
743
+ columns: [title, knowledgeForm]
734
744
  - name: All Notes
735
745
  type: table
736
746
  scopeProfileSlug: note
@@ -1160,6 +1170,10 @@ layout:
1160
1170
  appId: dashboard
1161
1171
  label: Brain Home
1162
1172
  icon: Home
1173
+ - kind: profile
1174
+ profileSlug: knowledge
1175
+ label: Knowledge
1176
+ icon: Brain
1163
1177
  - kind: profile
1164
1178
  profileSlug: note
1165
1179
  label: Notes
@@ -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
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Seed-obligation guard.
3
+ *
4
+ * THE BUG THIS EXISTS TO STOP, stated plainly: the applier has supported
5
+ * `seedEntities` and `suggestedRelations` since
6
+ * `create-workspace-from-definition.ts` learned to read them, and for 31 of 31
7
+ * shipped templates that support was exercised exactly zero times. The
8
+ * observable result is a workspace that installs 15 views over 0 entities —
9
+ * every board empty, every chart blank, and the user left to guess what the
10
+ * pack was for. Two real workspaces in the field reproduced this identically,
11
+ * which is the signature of a template defect rather than user behaviour.
12
+ *
13
+ * The rule: a template that ships VIEWS over profiles it OWNS must also ship
14
+ * at least one seed entity, so the workspace opens with something in it.
15
+ *
16
+ * The escape hatch is deliberate and narrow. `SEEDLESS_BY_DESIGN` lists the
17
+ * templates that predate this guard, each with the reason it is still empty.
18
+ * Adding a line there is legal; adding one silently is not, because the list is
19
+ * asserted to be exact — a template that gains seeds must leave the list in the
20
+ * same commit. That is what turns this from a lint into a shrinking backlog.
21
+ *
22
+ * What a seed is NOT: filler. Seeding three fake companies teaches nothing and
23
+ * gets deleted in the first minute. Seed the QUESTIONS, the stages, the
24
+ * checklist — the reusable part of the method. See `business-model.yaml`, which
25
+ * seeds the nine interrogations rather than an example business.
26
+ */
27
+
28
+ import { describe, expect, it } from "vitest";
29
+ import { WORKSPACE_TEMPLATES } from "./templates.js";
30
+ import type { WorkspaceYaml } from "./types.js";
31
+
32
+ /**
33
+ * Templates that ship views over their own profiles and NO seed entities.
34
+ *
35
+ * Every entry is a workspace that currently installs empty. Keep the reason
36
+ * honest — "not done yet" is a valid reason and a useful one, because it reads
37
+ * as debt instead of as a decision.
38
+ */
39
+ const SEEDLESS_BY_DESIGN: Record<string, string> = {
40
+ "agent-fleet": "Pre-dates this guard. Fleet contents are per-pod; needs a seed pass.",
41
+ base: "Pod substrate, not a workspace a user opens. Seeds would land in every workspace.",
42
+ "blockchain-ecosystem": "Pre-dates this guard; needs a seed pass.",
43
+ "brand-library": "Pre-dates this guard; brand assets are inherently per-company.",
44
+ "builder-workspace": "Pre-dates this guard; needs a seed pass.",
45
+ "business-developer": "Pre-dates this guard; overlay on CRM, seeds belong on the base.",
46
+ "civic-france": "Pre-dates this guard; needs a seed pass.",
47
+ communication: "Pre-dates this guard; needs a seed pass.",
48
+ "content-os": "Pre-dates this guard; needs a seed pass.",
49
+ "content-studio": "Pre-dates this guard; needs a seed pass.",
50
+ crm: "Pre-dates this guard; needs a seed pass.",
51
+ "dev-dashboard": "Pre-dates this guard; needs a seed pass.",
52
+ ecosystem: "Pre-dates this guard; needs a seed pass.",
53
+ "enterprise-os": "Pre-dates this guard; needs a seed pass.",
54
+ finance:
55
+ "Pre-dates this guard. Confirmed empty in the field: 15 views, 6 profiles, 0 entities. First candidate for a seed pass — a chart of accounts and a metric set are genuinely reusable.",
56
+ foundation:
57
+ "Pre-dates this guard. Strategic DNA is company-specific, but the INTERROGATION is not — see business-model.yaml, which seeds it as an overlay.",
58
+ grants: "Pre-dates this guard; needs a seed pass.",
59
+ hr: "Pre-dates this guard; needs a seed pass.",
60
+ "internal-runbook": "Pre-dates this guard; needs a seed pass.",
61
+ legal: "Pre-dates this guard; needs a seed pass.",
62
+ "life-os": "Pre-dates this guard; needs a seed pass.",
63
+ "marketing-campaign": "Pre-dates this guard; needs a seed pass.",
64
+ "meetings-knowledge": "Pre-dates this guard; needs a seed pass.",
65
+ networking: "Pre-dates this guard; needs a seed pass.",
66
+ operations: "Pre-dates this guard; needs a seed pass.",
67
+ "outreach-comms": "Pre-dates this guard; needs a seed pass.",
68
+ "project-management": "Pre-dates this guard; needs a seed pass.",
69
+ "research-base":
70
+ "Pre-dates this guard. Radar reproduced the empty-workspace pathology on top of this substrate; seed pass wanted.",
71
+ "research-decision": "Overlay on research-base; the method is a playbook, not entities.",
72
+ social: "Pre-dates this guard; needs a seed pass.",
73
+ "the-arch": "Pre-dates this guard; needs a seed pass.",
74
+ };
75
+
76
+ /** Slugs of profiles this template declares itself (owned or re-declared). */
77
+ function declaredProfileSlugs(tpl: WorkspaceYaml): Set<string> {
78
+ const profiles = (tpl as { profiles?: Array<{ slug?: string }> }).profiles ?? [];
79
+ return new Set(profiles.map((p) => p.slug).filter((s): s is string => Boolean(s)));
80
+ }
81
+
82
+ /** Views whose scope is a profile the template declares. */
83
+ function viewsOverOwnProfiles(tpl: WorkspaceYaml): number {
84
+ const declared = declaredProfileSlugs(tpl);
85
+ const views =
86
+ (tpl as { views?: Array<{ scopeProfileSlug?: string; scopeProfileSlugs?: string[] }> })
87
+ .views ?? [];
88
+ return views.filter((v) => {
89
+ const scoped = [v.scopeProfileSlug, ...(v.scopeProfileSlugs ?? [])].filter(Boolean);
90
+ return scoped.some((s) => declared.has(s as string));
91
+ }).length;
92
+ }
93
+
94
+ function seedCount(tpl: WorkspaceYaml): number {
95
+ const t = tpl as {
96
+ seedEntities?: unknown[];
97
+ suggestedEntities?: unknown[];
98
+ };
99
+ return (t.seedEntities?.length ?? 0) + (t.suggestedEntities?.length ?? 0);
100
+ }
101
+
102
+ const slugOf = (slug: string, tpl: WorkspaceYaml) =>
103
+ (tpl as { meta?: { slug?: string } }).meta?.slug ?? slug;
104
+
105
+ describe("workspace-templates — seed obligation (corpus)", () => {
106
+ it("every template with views over its own profiles ships seeds, or is declared seedless", () => {
107
+ const undeclared: string[] = [];
108
+
109
+ for (const [key, tpl] of Object.entries(WORKSPACE_TEMPLATES)) {
110
+ const slug = slugOf(key, tpl);
111
+ if (viewsOverOwnProfiles(tpl) === 0) continue;
112
+ if (seedCount(tpl) > 0) continue;
113
+ if (slug in SEEDLESS_BY_DESIGN) continue;
114
+ undeclared.push(
115
+ `${slug}: ships ${viewsOverOwnProfiles(tpl)} view(s) over its own profiles and 0 seed entities. ` +
116
+ `Add seedEntities (seed the METHOD, not fake data), or add "${slug}" to SEEDLESS_BY_DESIGN with a reason.`
117
+ );
118
+ }
119
+
120
+ expect(
121
+ undeclared,
122
+ `Templates would install a workspace of empty views:\n - ${undeclared.join("\n - ")}`
123
+ ).toEqual([]);
124
+ });
125
+
126
+ it("the seedless allowlist is exact — a template that gains seeds must leave it", () => {
127
+ const stale: string[] = [];
128
+
129
+ for (const [key, tpl] of Object.entries(WORKSPACE_TEMPLATES)) {
130
+ const slug = slugOf(key, tpl);
131
+ if (!(slug in SEEDLESS_BY_DESIGN)) continue;
132
+ if (seedCount(tpl) > 0) {
133
+ stale.push(`${slug}: now seeds ${seedCount(tpl)} entity(ies) — remove it from SEEDLESS_BY_DESIGN`);
134
+ }
135
+ }
136
+
137
+ const corpusSlugs = new Set(
138
+ Object.entries(WORKSPACE_TEMPLATES).map(([key, tpl]) => slugOf(key, tpl))
139
+ );
140
+ for (const slug of Object.keys(SEEDLESS_BY_DESIGN)) {
141
+ if (!corpusSlugs.has(slug)) {
142
+ stale.push(`${slug}: listed in SEEDLESS_BY_DESIGN but no such template exists`);
143
+ }
144
+ }
145
+
146
+ expect(stale, `Stale seedless allowlist:\n - ${stale.join("\n - ")}`).toEqual([]);
147
+ });
148
+
149
+ it("every seeded relation names entities seeded in the same template", () => {
150
+ // A suggested relation whose ref matches no seeded title fails SILENTLY at
151
+ // apply time — the workspace installs, the edge does not, and nothing says
152
+ // so. Catch the typo here instead of in a user's pod.
153
+ const dangling: string[] = [];
154
+
155
+ for (const [key, tpl] of Object.entries(WORKSPACE_TEMPLATES)) {
156
+ const slug = slugOf(key, tpl);
157
+ const t = tpl as {
158
+ seedEntities?: Array<{ title?: string }>;
159
+ suggestedEntities?: Array<{ title?: string }>;
160
+ suggestedRelations?: Array<{ sourceRef?: string; targetRef?: string; type?: string }>;
161
+ };
162
+ const rels = t.suggestedRelations ?? [];
163
+ if (rels.length === 0) continue;
164
+
165
+ const titles = new Set(
166
+ [...(t.seedEntities ?? []), ...(t.suggestedEntities ?? [])]
167
+ .map((e) => e.title)
168
+ .filter((x): x is string => Boolean(x))
169
+ );
170
+
171
+ for (const rel of rels) {
172
+ for (const ref of [rel.sourceRef, rel.targetRef]) {
173
+ if (!ref || titles.has(ref)) continue;
174
+ dangling.push(`${slug}: relation "${rel.type}" references unseeded entity "${ref}"`);
175
+ }
176
+ }
177
+ }
178
+
179
+ expect(dangling, `Dangling seeded relations:\n - ${dangling.join("\n - ")}`).toEqual([]);
180
+ });
181
+ });
@@ -122,8 +122,11 @@ describe("workspace-templates — shared ecosystem role SSOT (corpus)", () => {
122
122
  // Guards the derivation itself: if foundation ever stops declaring its
123
123
  // shared roles, rule (a) would silently guard nothing.
124
124
  expect(foundation, "foundation template must exist in the corpus").toBeDefined();
125
+ // `porteur` and `funder` join the relationship roles: a business model
126
+ // rests on WHO carries it and WHO funds it, and both are revocable hats on
127
+ // an existing person/company rather than kinds of their own.
125
128
  expect([...SHARED_ROLE_SLUGS].sort()).toEqual(
126
- ["churned", "client", "lead", "partner", "sponsor"]
129
+ ["churned", "client", "funder", "lead", "partner", "porteur", "sponsor"]
127
130
  );
128
131
  });
129
132