@synap-core/workspace-templates 0.11.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.
@@ -0,0 +1,102 @@
1
+ # France civic open-data research overlay — STUB (enhance later)
2
+ #
3
+ # Thin overlay that composes onto research-base for investigating French public
4
+ # data (open data / Licence Ouverte). NOT a government product, NOT a marketplace
5
+ # civic-tech app. Private workspace template only.
6
+ #
7
+ # Data access: consume official APIs only — data.gouv.fr, api.gouv.fr, PISTE.
8
+ # Never scrape HTML. Do not invent a public pod host or resource_shares.
9
+ # No automations that fetch the web in this stub.
10
+
11
+ meta:
12
+ slug: civic-france
13
+ domain: knowledge
14
+ name: France Civic Research
15
+ description: Overlay for investigating French public data (open data / Licence Ouverte). Composes onto Research. Not a government product — a private research stub.
16
+ icon: landmark
17
+ color: "#1D4ED8"
18
+ tags: [research, civic, france, open-data, licence-ouverte]
19
+ isPublic: false
20
+
21
+ workspace:
22
+ name: Research
23
+ description: Adds a French civic open-data lens onto the Research substrate — track public datasets consulted during investigations.
24
+ subtype: research-base
25
+
26
+ # Layer additively onto the research-base workspace (no second workspace).
27
+ # Visibility and capabilities OMITTED so the base Research workspace keeps its own.
28
+ dependencies:
29
+ - slug: research-base
30
+ relation: compose
31
+ reason: France civic research is a research method — it needs the Research substrate (question / research / source) to work over.
32
+
33
+ profiles:
34
+ # Slugs this overlay reuses from research-base — declared for the per-file
35
+ # validator (base owns their full schema; the applier skips already-existing
36
+ # properties). Mirror research-decision's reuse pattern.
37
+ - slug: question
38
+ displayName: Question
39
+ icon: help-circle
40
+ color: blue
41
+ description: An open question driving a civic-data investigation.
42
+ properties:
43
+ - slug: questionStatus
44
+ label: Status
45
+ valueType: string
46
+ inputType: select
47
+ enumValues: [open, exploring, answered, abandoned]
48
+ constraints: { defaultValue: open }
49
+
50
+ - slug: research
51
+ displayName: Research
52
+ icon: microscope
53
+ color: violet
54
+ description: An investigation into French public data — sources, findings, conclusion.
55
+ properties:
56
+ - slug: researchStatus
57
+ label: Status
58
+ valueType: string
59
+ inputType: select
60
+ enumValues: [ongoing, concluded, abandoned]
61
+ constraints: { defaultValue: ongoing }
62
+
63
+ - slug: source
64
+ displayName: Source
65
+ icon: file-text
66
+ color: cyan
67
+ description: Anything consulted — API payload, dataset page, decree, report.
68
+ properties:
69
+ - slug: url
70
+ label: URL
71
+ valueType: string
72
+ inputType: url
73
+
74
+ # Workspace-owned catalog entry for a French public dataset (thin stub).
75
+ - slug: public_dataset
76
+ displayName: Public dataset
77
+ icon: database
78
+ color: blue
79
+ description: A French public dataset consulted via official open-data APIs (data.gouv.fr / api.gouv.fr / PISTE) — never scraped HTML.
80
+ scope: WORKSPACE
81
+ properties:
82
+ - slug: sourceUrl
83
+ label: Source URL
84
+ valueType: string
85
+ inputType: url
86
+ - slug: license
87
+ label: License
88
+ valueType: string
89
+ inputType: text
90
+ - slug: publisher
91
+ label: Publisher
92
+ valueType: string
93
+ inputType: text
94
+
95
+ views:
96
+ - name: Public datasets
97
+ slug: civic-france-public-datasets
98
+ type: table
99
+ scopeProfileSlug: public_dataset
100
+ description: French public datasets tracked in this research workspace.
101
+ config:
102
+ columns: [title, publisher, license, sourceUrl]
@@ -0,0 +1,97 @@
1
+ /**
2
+ * FRESHNESS GATE for the BUILT `dist/` artifact.
3
+ *
4
+ * WHY (read before deleting):
5
+ * `templates-freshness.test.ts` (sibling file) proves `src/templates.ts`
6
+ * matches `src/*.yaml`. That is necessary but not sufficient: `dist/` — built
7
+ * by `tsup` from `src/templates.ts` — is what `synap-backend` and every other
8
+ * consumer actually imports as the published `@synap-core/workspace-templates`
9
+ * npm dependency (see this package's `exports` field: `dist/index.js`).
10
+ * Nothing regenerates `dist/` automatically, and it is committed independently
11
+ * of `src/templates.ts`. A YAML edit can reach `src/templates.ts` (green on
12
+ * the sibling test) and NOT reach `dist/` — the sibling test stays green while
13
+ * the backend keeps reading stale data, because it never looks at `dist/` at
14
+ * all. That gap is exactly what happened on 2026-09-05.
15
+ *
16
+ * This test closes it by importing the BUILT `dist/index.js` module (not
17
+ * re-parsing its text — a bundled/minified file has no byte-for-byte
18
+ * relationship to `src/templates.ts`) and asserting its exported
19
+ * `WORKSPACE_TEMPLATES` deep-equals what `src/*.yaml` renders to right now.
20
+ * That is the same non-vacuous shape as the sibling test: it re-derives the
21
+ * expected value from the YAML source on every run rather than trusting any
22
+ * stored marker, and it asserts the corpus is non-empty so a broken import
23
+ * can't silently compare `{}` to `{}`.
24
+ */
25
+
26
+ import { describe, expect, it } from "vitest";
27
+ import { readYamlTemplates } from "./generate.mjs";
28
+
29
+ const FIX_COMMAND =
30
+ "cd synap-app/packages/workspace-templates && pnpm build";
31
+
32
+ describe("dist/ freshness", () => {
33
+ it("dist/index.js's WORKSPACE_TEMPLATES matches what src/*.yaml renders to", async () => {
34
+ const expectedEntries = readYamlTemplates().map((t) => [t.slug, t.data]);
35
+ const expected = Object.fromEntries(expectedEntries);
36
+
37
+ // Guard against a vacuous pass: if the YAML corpus itself came back empty
38
+ // (a broken readdir, an emptied src/ directory), an empty-vs-empty
39
+ // comparison below would prove nothing. Fail loudly instead.
40
+ if (expectedEntries.length === 0) {
41
+ throw new Error(
42
+ "readYamlTemplates() returned zero templates — src/*.yaml did not " +
43
+ "parse. This test cannot verify dist/ freshness against an empty " +
44
+ "expected corpus; fix the YAML read before trusting this gate.",
45
+ );
46
+ }
47
+
48
+ let dist: { WORKSPACE_TEMPLATES?: Record<string, unknown> };
49
+ try {
50
+ // dist/index.js is the BUILT artifact — import it live so we're
51
+ // comparing against exactly what a consumer (synap-backend, the CLI)
52
+ // resolves via this package's `exports` field, not a re-parse of its
53
+ // source text.
54
+ dist = await import("../dist/index.js");
55
+ } catch (err) {
56
+ throw new Error(
57
+ [
58
+ "",
59
+ "❌ dist/index.js could not be imported — it is missing or fails to load.",
60
+ "",
61
+ ` FIX — build it:`,
62
+ ` ${FIX_COMMAND}`,
63
+ "",
64
+ ` (original error: ${(err as Error).message})`,
65
+ "",
66
+ ].join("\n"),
67
+ );
68
+ }
69
+
70
+ const actual = dist.WORKSPACE_TEMPLATES;
71
+ if (!actual || Object.keys(actual).length === 0) {
72
+ throw new Error(
73
+ "dist/index.js loaded but exported no WORKSPACE_TEMPLATES (or an " +
74
+ `empty object) — it is stale or broken.\n\n FIX:\n ${FIX_COMMAND}\n`,
75
+ );
76
+ }
77
+
78
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
79
+ throw new Error(
80
+ [
81
+ "",
82
+ "❌ dist/index.js is STALE — its WORKSPACE_TEMPLATES no longer matches",
83
+ " what src/*.yaml renders to. synap-backend and every other consumer",
84
+ " resolve this package to dist/, not src/, so a stale dist/ means the",
85
+ " backend is reading templates that no longer exist on disk — even",
86
+ " though src/templates.ts (and templates-freshness.test.ts) are fresh.",
87
+ "",
88
+ " FIX — rebuild and commit the result:",
89
+ ` ${FIX_COMMAND}`,
90
+ "",
91
+ ].join("\n"),
92
+ );
93
+ }
94
+
95
+ expect(actual).toEqual(expected);
96
+ });
97
+ });
@@ -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
@@ -1020,15 +1020,26 @@ playbooks:
1020
1020
  params:
1021
1021
  - { name: contract, type: entity, profileSlug: contract, label: Engagement, required: true }
1022
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
1023
1028
  # Generic stages, mapped onto grant_submission's own stage/status/outcome +
1024
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.
1025
1036
  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." }
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." }
1032
1043
  executor: is-agent
1033
1044
  grants:
1034
1045
  - entity.create
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
@@ -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