@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.
package/src/the-arch.yaml CHANGED
@@ -50,6 +50,10 @@ dependencies:
50
50
  kind: workspace
51
51
  relation: require
52
52
  reason: "The engineering / ops dashboard — the build-side view of the company."
53
+ - slug: networking
54
+ kind: workspace
55
+ relation: require
56
+ reason: "The partner-intro layer — need → intro-matcher → intro → billed_as → deal, with the A↔B confidentiality firewall; the Stellar Client-Sync emits needs that depend on it."
53
57
 
54
58
  workspace:
55
59
  name: The Arch
package/src/types.ts CHANGED
@@ -607,46 +607,44 @@ export interface TemplateExtendsRef {
607
607
  * PACKAGE_DEPENDENCY_KINDS — the dependency-kind vocabulary, declared ONCE.
608
608
  *
609
609
  * This tuple exists so consumers can IMPORT the vocabulary instead of
610
- * hand-copying it. `TemplateDependency` is currently mirrored by hand in six
611
- * other places, four of which carry a comment promising they match "VERBATIM so
612
- * the wire shape never drifts" — a comment doing a type's job. A comment cannot
613
- * fail a build; an import can. Those mirrors live in repos this package does not
614
- * own, so this tuple does not fix them by existing — it makes the fix possible:
610
+ * hand-copying it. `TemplateDependency` is mirrored by hand in several other
611
+ * places — a comment cannot fail a build, an import can, but these mirrors live
612
+ * in repos this package does not own, so they must match this list EXACTLY:
615
613
  *
616
- * - marketplace `types.ts:102`
617
- * - onboarding `workspace-templates.ts:27`
618
- * - synap-backend `package-definition.ts:204` (exports its own
619
- * `PackageDependencyKind` — the closest peer)
620
- * - hub-protocol REST `rest/packages.ts:140` (zod enum)
621
- * - browser `InstallPackageDialog.tsx:262`
622
- * - control-plane-api `routes/packages.ts:436` (zod enum)
614
+ * - marketplace `features/marketplace/src/types.ts`
615
+ * - onboarding `features/onboarding/src/{templates/workspace-templates,components/WorkspacePreview}.ts(x)`
616
+ * - browser `InstallPackageDialog.tsx`
617
+ * - synap-backend `package-definition.ts` (`PackageDependencyKind`)
618
+ * - hub-protocol REST `rest/packages.ts` (zod enum)
619
+ * - control-plane-api `db/schema/packages.ts` (`TemplateDependency` type)
620
+ * + `routes/packages.ts` (`DEPENDENCY_KINDS` zod enum)
623
621
  *
624
- * ⚠️ `'automation'` IS RETIRED VOCABULARY, deliberately still listed here.
625
- * CP migration `0049_package_vocabulary` retired the `automation` package
626
- * category in favour of `workflow` ("workflow — a triggered/sequenced run
627
- * (absorbs 'automation' + 'playbook')", `PACKAGE_TYPES` in CP
628
- * `db/schema/packages.ts`). A dependency's `kind` names the TYPE of package it
629
- * points at, so this vocabulary should track `PACKAGE_TYPES` — meaning the live
630
- * spelling is `workflow`, not `automation`.
622
+ * These are the 7 literals the wire accepts, in lockstep with the CP publish
623
+ * enum (`DEPENDENCY_KINDS = [...PACKAGE_TYPES, "automation"]`, CP
624
+ * `routes/packages.ts`), the hub install enum (`rest/packages.ts`), and the
625
+ * backend resolver's `PackageDependencyKind` (`@synap/database`).
631
626
  *
632
- * It is NOT renamed here, on purpose. This is latent, not live: 0/21 templates
633
- * declare any `kind` other than the default `'workspace'`. And all four wire
634
- * enums (CP + hub zod, above) still accept `'automation'` and do NOT accept
635
- * `'workflow'` — so emitting `'workflow'` today would be rejected with a 400 at
636
- * both doors, and dropping `'automation'` from this tuple would make any
637
- * consumer that derives a zod enum from it start rejecting a value the wire
638
- * still legally accepts (a published third-party package may carry it; CP's own
639
- * schema permits it). Narrowing must therefore follow the wire, not lead it.
627
+ * Resolver behaviour (backend `package-dependency-resolver.ts`):
628
+ * - `workspace` → resolve+install; the ONLY kind that may `compose`.
629
+ * - `capability` / `cell` → resolve+install, always `require`d (presence).
630
+ * - `skill` / `workflow` / `view` / `automation` → ACCEPTED and `require`d
631
+ * present, surfaced as `required-absent` (non-fatal) — no standalone by-slug
632
+ * applier on the pod yet; when one grows, give the kind its own branch.
640
633
  *
641
- * ORDERED FIX (each step deployable alone, no flag day):
642
- * 1. Widen the CP + hub zod enums to accept BOTH `'automation'` and `'workflow'`.
643
- * 2. Add `'workflow'` here; mark `'automation'` deprecated; migrate any authored use.
644
- * 3. Once no payload carries `'automation'`, drop it from the wire enums and this tuple.
634
+ * `'automation'` is legacy vocabulary: CP migration `0049_package_vocabulary`
635
+ * renamed the `automation` package category → `workflow` (which absorbs
636
+ * automation + playbook, `PACKAGE_TYPES` in CP `db/schema/packages.ts`). It
637
+ * stays accepted on the wire — and therefore in this tuple — for back-compat
638
+ * with dependencies published before the rename.
645
639
  */
646
640
  export const PACKAGE_DEPENDENCY_KINDS = [
647
641
  "workspace",
648
642
  "capability",
649
- /** @deprecated Retired by CP 0049 in favour of `workflow`. See note above. */
643
+ "skill",
644
+ "workflow",
645
+ "view",
646
+ "cell",
647
+ /** @deprecated Legacy alias for `workflow` (CP 0049). Still accepted on the wire. */
650
648
  "automation",
651
649
  ] as const;
652
650
 
@@ -658,8 +656,8 @@ export interface TemplateDependency {
658
656
  slug: string;
659
657
  /**
660
658
  * What the dependency is. Default `'workspace'`. Only `'workspace'`
661
- * dependencies can be `compose`d; `'capability'`/`'automation'` are always
662
- * `require`d present without merging.
659
+ * dependencies can be `compose`d; every other kind is always `require`d
660
+ * present without merging.
663
661
  */
664
662
  kind?: PackageDependencyKind;
665
663
  /**
@@ -667,7 +667,17 @@ function collectLoopBindingRefs(data: unknown): string[] {
667
667
  return;
668
668
  }
669
669
  if (isRecord(v)) {
670
- for (const x of Object.values(v)) walk(x);
670
+ // Skip AUTHORING NOTES. An `_`-prefixed key is documentation the runtime
671
+ // never evaluates (`_filterNote` and friends explain what DOWNSTREAM
672
+ // nodes do, and so legitimately quote `{{loop.item...}}` in prose).
673
+ // Reading them as live references made a correct flow fail validation:
674
+ // `stellar-grant-client` was reported as "references {{loop.item}} but is
675
+ // not inside a loop body" purely because its note DESCRIBED the nodes
676
+ // that do. Kept identical in all three copies of this walker.
677
+ for (const [k, x] of Object.entries(v)) {
678
+ if (k.startsWith("_")) continue;
679
+ walk(x);
680
+ }
671
681
  }
672
682
  };
673
683
  walk(data);
@@ -80,6 +80,92 @@ describe("validateTemplate — clean template", () => {
80
80
  });
81
81
  });
82
82
 
83
+ describe("validateTemplate — system Knowledge contract", () => {
84
+ const template = (overrides: Record<string, unknown> = {}): WorkspaceYaml =>
85
+ ({
86
+ meta: {
87
+ slug: "knowledge-consumer",
88
+ name: "Knowledge consumer",
89
+ description: "d",
90
+ icon: "brain",
91
+ color: "#000000",
92
+ },
93
+ workspace: { name: "Knowledge consumer", description: "d" },
94
+ profiles: [{ slug: "gadget", displayName: "Gadget", properties: [] }],
95
+ views: [
96
+ {
97
+ name: "Knowledge",
98
+ type: "table",
99
+ scopeProfileSlug: "knowledge",
100
+ config: { columns: ["title", "knowledgeForm"] },
101
+ },
102
+ ],
103
+ ...overrides,
104
+ }) as WorkspaceYaml;
105
+
106
+ it("allows templates to consume system Knowledge without redeclaring it", () => {
107
+ const result = validateTemplate(
108
+ template({
109
+ seedEntities: [
110
+ {
111
+ profileSlug: "knowledge",
112
+ title: "Write migrations that preserve meaning",
113
+ properties: { knowledgeForm: "insight" },
114
+ },
115
+ ],
116
+ })
117
+ );
118
+
119
+ expect(result, JSON.stringify(result.errors)).toEqual({ ok: true, errors: [] });
120
+ });
121
+
122
+ it("rejects a template-owned Knowledge profile", () => {
123
+ const result = validateTemplate(
124
+ template({
125
+ profiles: [
126
+ {
127
+ slug: "knowledge",
128
+ displayName: "Knowledge",
129
+ properties: [
130
+ {
131
+ slug: "ek_type",
132
+ label: "Type",
133
+ valueType: "string",
134
+ inputType: "select",
135
+ enumValues: ["gotcha", "lesson"],
136
+ },
137
+ ],
138
+ },
139
+ ],
140
+ })
141
+ );
142
+
143
+ expect(result.ok).toBe(false);
144
+ expect(
145
+ result.errors.some((error) => error.rule === "system-profile-redefined")
146
+ ).toBe(true);
147
+ });
148
+
149
+ it("rejects a legacy or missing Knowledge form on seeded Knowledge", () => {
150
+ const result = validateTemplate(
151
+ template({
152
+ seedEntities: [
153
+ {
154
+ profileSlug: "knowledge",
155
+ title: "Legacy knowledge",
156
+ properties: { ek_type: "gotcha" },
157
+ },
158
+ ],
159
+ })
160
+ );
161
+
162
+ expect(result.ok).toBe(false);
163
+ expect(
164
+ result.errors.map((error) => error.rule).sort()
165
+ ).toEqual(["knowledge-seed-form-invalid", "knowledge-seed-legacy-form"]);
166
+ });
167
+ });
168
+
83
169
  // Regression: before wiring `validateFlowDefinition` into validateTemplate, a
84
170
  // template automation with a broken FLOW (bogus node type / dangling edge / cycle
85
171
  // / typo'd channelType) passed the template gate green and then silently never
package/src/validate.ts CHANGED
@@ -159,6 +159,22 @@ export const SYSTEM_PROFILES = new Set([
159
159
  "report",
160
160
  ]);
161
161
 
162
+ /**
163
+ * System-owned profiles whose schema is defined by the pod, rather than by a
164
+ * workspace template. A template may scope a view, link, sidebar item, or seed
165
+ * entity to `knowledge` because it is in {@link SYSTEM_PROFILES}; it must never
166
+ * declare the profile itself. Re-declaring it would turn a shared contract into
167
+ * whichever template happened to be applied first.
168
+ *
169
+ * `knowledgeForm` is intentionally an enum property on the Knowledge kind, not
170
+ * a facet/role: a knowledge entity has exactly one form, while Synap roles are
171
+ * additive. Keep this guard local to the template corpus; the actual profile
172
+ * schema and its migration are owned by the backend seeder.
173
+ */
174
+ const TEMPLATE_FORBIDDEN_SYSTEM_PROFILE_SLUGS = new Set(["knowledge"]);
175
+ const KNOWLEDGE_FORMS = new Set(["insight", "caution"]);
176
+ const LEGACY_KNOWLEDGE_SEED_FIELDS = new Set(["ek_type", "type"]);
177
+
162
178
  // ── Dependency-vocabulary closure (compose vs require semantics) ─────────────
163
179
 
164
180
  /**
@@ -396,6 +412,20 @@ export function validateTemplate(
396
412
  const known = (s: string) =>
397
413
  slugs.has(s) || SYSTEM_PROFILES.has(s) || baseProfileSlugs.has(s);
398
414
 
415
+ // ── System-owned Knowledge contract ─────────────────────────────────────
416
+ // `knowledge` is available to every template through SYSTEM_PROFILES. It is
417
+ // not an overlay point: allowing a template to declare it lets a workspace
418
+ // fork its base property definitions, enum vocabulary, or profile row-level
419
+ // metadata. Templates consume it through views/links/seeds only.
420
+ for (const profile of tpl.profiles || []) {
421
+ if (!TEMPLATE_FORBIDDEN_SYSTEM_PROFILE_SLUGS.has(profile.slug)) continue;
422
+ add(
423
+ "system-profile-redefined",
424
+ `profiles.${profile.slug}`,
425
+ `Profile "${profile.slug}" is system-owned. Reference it from views, links, sidebar items, or seedEntities; do not declare template-owned properties or metadata.`
426
+ );
427
+ }
428
+
399
429
  // Views: structured views need a scope; scoped profiles must resolve.
400
430
  for (const v of tpl.views || []) {
401
431
  const type = String(v.type || "").toLowerCase();
@@ -579,6 +609,25 @@ export function validateTemplate(
579
609
  "seedEntities",
580
610
  `seedEntity references unknown profile "${s.profileSlug}"`
581
611
  );
612
+
613
+ if (s.profileSlug !== "knowledge") continue;
614
+ const properties = s.properties ?? {};
615
+ for (const field of LEGACY_KNOWLEDGE_SEED_FIELDS) {
616
+ if (!(field in properties)) continue;
617
+ add(
618
+ "knowledge-seed-legacy-form",
619
+ `seedEntities.${s.title}.properties.${field}`,
620
+ `Knowledge seed entities use "knowledgeForm" ("insight" | "caution"), not the legacy "${field}" field.`
621
+ );
622
+ }
623
+ const form = properties.knowledgeForm;
624
+ if (!KNOWLEDGE_FORMS.has(String(form))) {
625
+ add(
626
+ "knowledge-seed-form-invalid",
627
+ `seedEntities.${s.title}.properties.knowledgeForm`,
628
+ `Knowledge seed entities require knowledgeForm to be "insight" or "caution".`
629
+ );
630
+ }
582
631
  }
583
632
 
584
633
  // profileSlug referential integrity, part 2 — every OTHER site that names a