From fc6c685de59871e4cadf0cc2435a0a1a8a3bb749 Mon Sep 17 00:00:00 2001 From: Headgent Date: Sat, 15 Aug 2026 09:20:38 +0200 Subject: [PATCH] =?UTF-8?q?docs(skills):=20AK5=20WIE-Bereinigung=20?= =?UTF-8?q?=E2=80=94=20SKILL.md=20auf=20WAS-Rolle=20geschaerft=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(skills): AK5 — Generat-/Builder-Mechanik hinter typisierte Verweise, Widersprueche zu platform-versioning aufgeloest * docs(skills): platform-usage — Kernel-/core-app-Interna hinter oeffentliche Form + Skill-Verweis (Nachtrag AK5) --- skills/jardis-mcp-consumer/SKILL.md | 29 +++++++---------- skills/platform-cookbook/SKILL.md | 20 ++++++------ skills/platform-implementation/SKILL.md | 12 +++---- skills/platform-usage/SKILL.md | 6 ++-- skills/platform-versioning/SKILL.md | 32 ++++++------------- skills/platform-workflow/SKILL.md | 42 +++++-------------------- skills/rules-patterns/SKILL.md | 2 +- 7 files changed, 48 insertions(+), 95 deletions(-) diff --git a/skills/jardis-mcp-consumer/SKILL.md b/skills/jardis-mcp-consumer/SKILL.md index c0ae6bf..0f0ce94 100644 --- a/skills/jardis-mcp-consumer/SKILL.md +++ b/skills/jardis-mcp-consumer/SKILL.md @@ -25,11 +25,11 @@ addresses next. The stretch is the transport (MCP calls instead of UI clicks), n are read-only projections of the current on-disk state — call them again after a write to see the effect, there is no push/subscribe. -The full catalogue (on the order of 60 tools plus 50+ resources/templates; the exact numbers -are pinned by the live test `TestToolBudget_FinalCount` and mirrored in `INVENTAR.md`'s -Budget-Tracking section — do not hardcode them from memory, they grow with every strategic-design -increment) is a lived artefact, not something to memorise here — consult it before guessing a -name (see Reference). +The full catalogue (on the order of 60 tools plus 50+ resources/templates — do not hardcode them +from memory, they grow with every strategic-design increment; how the count is pinned and kept +honest: [[beschreibt-bauweise-von::builder-mcp-bauweise]] §9, mirrored in `INVENTAR.md`'s +Budget-Tracking section) is a lived artefact, not something to memorise here — consult it before +guessing a name (see Reference). ### 2. End-to-end workflow @@ -122,19 +122,12 @@ invent a tool call for this — there isn't one. ### 5. Freshness/Drift at startup Every MCP session start (`New`) carries a live self-check into the server's `initialize` -Instructions: the running `jardis` binary's embedded VCS stamp (commit, commit time) is compared -against the git repository the binary's own executable sits in, checked live — not cached, not -computed once at build time. Two independent, complementary signals: **staleCommit** (the -embedded revision is not the repo's current HEAD — the binary is behind the source it claims to -run) and **dirtyNow** (the repo's working tree has uncommitted changes right now — a binary can -never fully reflect an as-yet-uncommitted edit, no matter when it was built). A clean, current -binary prints one line — `jardis -- Repo-Stand aktuell (Commit , sauberer -Arbeitsbaum).`; drift instead prints a multi-line `WARNUNG` block naming the embedded vs. live -commit and/or the uncommitted-change count. This check never blocks or refuses to start (an -outage would be a heavier intervention than the risk it guards against) — it only reports. A -client should read this banner before trusting a reported finding: a stale binary can silently -still be missing capabilities or fixes (including ones documented in this very skill set) that -only exist in the newer source it has fallen behind. +Instructions, comparing the running binary's embedded revision against the repository it sits +in — the two signals, exact wording, and why it never blocks: +[[beschreibt-bauweise-von::builder-mcp-bauweise]] §8. A client should read this banner before +trusting a reported finding: a stale binary can silently still be missing capabilities or fixes +(including ones documented in this very skill set) that only exist in the newer source it has +fallen behind. ### 6. Error ergonomics diff --git a/skills/platform-cookbook/SKILL.md b/skills/platform-cookbook/SKILL.md index 9fa4175..84a715f 100644 --- a/skills/platform-cookbook/SKILL.md +++ b/skills/platform-cookbook/SKILL.md @@ -213,7 +213,7 @@ Substitute the actual root identifier name (e.g. `counterId`, `meterNumber`) for **Business-key resolution (G4 / X-2).** The Generator picks the root identifier by walking the entity for a Single-Column-Unique-Index on a NOT-NULL `string` column. If exactly one such column exists, that is the business key and surfaces in the response. If none exists, the response falls back to the internal `int` PK property (e.g. `counterGatewayId: int` for a keyless `counterGateway` child — not a defect, the only available identity). If multiple ambiguous candidates exist (X-2: two NOT-NULL-unique-string columns), the Build aborts — model an explicit single business key in the Schema instead of letting the response shape become non-deterministic. -**Query projection.** The Generator emits per BC a `{BC}/FieldMap.php` (ForceOverwrite — a pure naming container with one `{table}Columns()` method per BC table, the write-path DTO→column map; there is no `Fields()` method). `FieldMapper::fromAggregate($data, $mapProvider, '')` walks the entity map (3rd arg = the current entity-level name; the provider supplies per-entity column maps) and renames columns to field names. The **read** projection — internal-PK strip where a business key exists (G4) plus root-id normalization — is aggregate-structural and runs at the **aggregate read edge (the query handler)**, not in FieldMap: the handler keeps the root entity's PK as the leading read field `'id'` (normalised name; real PK column from the schema). The projected Akte therefore always carries the root id — the internal handle the ById/ByIds read base and the bulk-read recipe rely on; child entities stay id-free. It further strips internal FK columns entirely (the nesting replaces them), collapses pure-join tables (F3.1: tables that exist only as two FKs plus a composite PK — they disappear into the parent-child relation in the projected tree), and keeps `DateTimeImmutable` blade values inert — JSON/CLI serialization is the caller's job (G5). +**Query projection.** The Generator emits per BC a `{BC}/FieldMap.php` (ForceOverwrite — a pure naming container with one `{table}Columns()` method per BC table, the write-path DTO→column map; there is no `Fields()` method). The **read** projection (internal-PK strip where a business key exists, G4; root-id normalization; FK-column strip; pure-join collapse, F3.1) is aggregate-structural and runs at the **aggregate read edge (the query handler)**, not in FieldMap — traversal mechanics: [[beschreibt-bauweise-von::builder-generat-bauweise]] §8. The projected Akte therefore always carries the root id — the internal handle the ById/ByIds read base and the bulk-read recipe rely on; child entities stay id-free. `DateTimeImmutable` blade values stay inert — JSON/CLI serialization is the caller's job (G5). **Command response** never carries domain state — only the identifier(s) the caller needs to address what just changed (event-sourcing / correlation). For the full state after a write, the caller issues the matching read-base query — `get{Agg}By{UniqueKey}` with the echoed business key, or `get{Agg}ById` (CQRS). @@ -280,7 +280,7 @@ protected function reason(WorkflowContextInterface $context): mixed **Event bubbling (flat, Domain-scope only):** `EventScope::Domain` events from the sub-`DomainResponse` are collected flat into the `data` return array. The main-process orchestrator harvests them identically to events from any other node (`getChain()` → `$data[EventScope::Domain->value]` → `addEvent(…, Domain)`). `Internal` events of the sub-process stay sub-process-internal (they are not returned). -**Routing (`onFail`):** add an `onFail` edge from the sub-process node in the Process Designer — the Generator's `statusSetFromEdges` (a build-time Go function, `extensionprocess/node.go`) derives the node's status set from the drawn edges, so the `onFail` transition surfaces in the generated routing automatically. `onFail` = the sub-process run broke (exception or `InternalError` response); a fachliches Verdikt (true/false) is data and is routed via a downstream decision node. +**Routing (`onFail`):** add an `onFail` edge from the sub-process node in the Process Designer — the node's status set is derived from the drawn edges (mechanics: [[beschreibt-bauweise-von::builder-generat-bauweise]] §6.1), so the `onFail` transition surfaces in the generated routing automatically. `onFail` = the sub-process run broke (exception or `InternalError` response); a fachliches Verdikt (true/false) is data and is routed via a downstream decision node. **`subprocessOnly` flag:** a process that is only called as a sub-process (never directly via `$bc->process()`) should have `subprocessOnly: true` in its YAML (UI toggle „In API sichtbar", default ON). This suppresses the thin-dispatch method on the `{BC}Process` facade — the process DTO, orchestrator, and node stubs are always generated regardless of the flag. @@ -291,7 +291,7 @@ protected function reason(WorkflowContextInterface $context): mixed **Recipe 9 — Cross-BC write: translate → foreign `process()` → map response (G7)** -A Cross-BC-Call node whose target **mutates** state in a foreign BC must target that BC's **process**, never its aggregate — the Designer/Validator enforce this (`V-XBC-WRITE-TARGET`, appsvc-Handler-Layer): `consumedCalls` for a foreign write offers only process methods of the target BC. Reads stay on the foreign `{Agg}Read` (unrestricted, no Prozess-Zwang). The generated Service (`{Domain}/Service/.php`, `platform-implementation` §1) is the ACL — it never passes the caller's DTO through unchanged. +A Cross-BC-Call node whose target **mutates** state in a foreign BC must target that BC's **process**, never its aggregate — the Designer/Validator enforce this (`V-XBC-WRITE-TARGET`; the same rule is additionally sealed as a PHPStan boundary gate, [[beschreibt-bauweise-von::builder-generat-bauweise]] §2): `consumedCalls` for a foreign write offers only process methods of the target BC. Reads stay on the foreign `{Agg}Read` (unrestricted, no Prozess-Zwang). The generated Service (`{Domain}/Service/.php`, `platform-implementation` §1) is the ACL — it never passes the caller's DTO through unchanged. ```php // {Domain}/Service/.php — generated scaffolding, __invoke() body is yours @@ -428,13 +428,11 @@ ausfällt (MySQL liest es zufällig richtig als Konflikt, Postgres fälschlich a vergabe). Details, Ursache und Package-Folgeposten: `wissensbasis/rowcount-cas-ist-treiberabhaengig.md`. **Statusverhalten am Prozessende.** Ein Torwächter-Konflikt muss als 409 nach außen sichtbar -werden, nicht nur intern routen. Ableitungsregel, zweistufig: - -1. Je Knoten-Identität (`getHandlerFqcn()`) zählt nur der Status der **letzten** Ausführung. -2. Das erste 4xx-Kettenglied, dessen Knoten laut Schritt 1 **zuletzt ebenfalls 4xx** war, liefert - den Antwortstatus. Ein Torwächter, der beim Retry gelingt, trägt seinen frühen Fehlschlag nicht - mehr in den Antwortstatus — ohne diese Verfeinerung meldet ein geheilter Retry fälschlich 409 - trotz tatsächlichem Erfolg samt Seiteneffekt (belegt, Postgres-Nummernkreis, s. u.). +werden, nicht nur intern routen — der zweistufige Kettenscan, der das leistet (nur der Status der +letzten Ausführung je Knoten-Identität zählt, damit ein beim Retry gelingender Torwächter seinen +frühen Fehlschlag nicht mehr in den Antwortstatus trägt): [[beschreibt-bauweise-von::builder-generat-bauweise]] +§5.4. Ohne diese Verfeinerung meldet ein geheilter Retry fälschlich 409 trotz tatsächlichem Erfolg +samt Seiteneffekt (belegt, Postgres-Nummernkreis, s. u.). Das ist die Drei-Ebenen-Trennung aus `platform-workflow` §1: **Verzweigung** (ON_SUCCESS/ON_FAIL = true/false, reine Wegwahl) · **Antwort-Status** (immer aus der tatsächlichen `DomainResponse` @@ -488,7 +486,7 @@ Torwächter. | `LogicException: Cannot resolve ClassName` | BC vs. Aggregate segment swapped in namespace | Namespace is `\\Aggregate\\…` — BC and Aggregate are two segments even when they share a name (the `Aggregate/` segment sits between them) | | Edit under `{BC}/Aggregate/{Agg}/` gone after rebuild | The **whole** aggregate tree is hermetic (ForceOverwrite, V1) — every build truncates and rewrites it; there is no override slot inside it | Move the behaviour to a **Process** (`{BC}/Process//Command/Handler/Action/`), re-model the aggregate in the Designer, or (tenant variant) author a `v{N}/.php` next to the baseline (`platform-versioning` §1) | | `on()` edit gone after rebuild | Bodies were filled in the hermetic `EventRouter.php` | Never edit the router — author transport in a **Process node** that publishes `$response->getEvents()` after the aggregate command (§1) | -| Versioned override (`v2/…`) ignored | Domain facade isn't passing `'v2'` as `$version`, or a needed `ClassVersionConfig` fallback entry is missing | Thread the version through the call (`$bc->{agg}()->getCounterById($dto, 'v2')` for reads, or `$this->handle(Counter::class)->createCounter($dto, 'v2')` family-internally for writes) or set a `version()` default on `.php`; the variant must sit at `{Agg}/…/v2/.php` (immediate neighbour of the baseline) — `platform-versioning` §1 | +| Versioned override (`v2/…`) ignored | The call isn't passing `'v2'` as `$version`, or a needed `ClassVersionConfig` fallback entry is missing | Thread the version through the call (`$bc->{agg}()->getCounterById($dto, 'v2')` for reads, or `$this->handle(Counter::class)->createCounter($dto, 'v2')` family-internally for writes) — there is **no** domain-wide `version()` default to set instead, the per-call argument is the only lever; the variant must sit at `{Agg}/…/v2/.php` (immediate neighbour of the baseline) — `platform-versioning` §1 | | Process node body lost after rebuild | The custom node lost its `@node-id` marker, or the file had broken syntax so the body-preserve merger could not parse it | Keep the generated `@node-id` DocBlock marker intact; fix the parse error. The merger regenerates the node *header* but preserves the body keyed by `@node-id`. (The Designer's "Force" build path deliberately overwrites a node body.) | | Process node not invoked though it's in the graph | R5-Routing-Safety: the node isn't registered via `addNode()`, or the returned `ON_*` status has no transition in the current node | Every handler referenced in `->onSuccess()/onFail()/…` must be declared as its own `->node(...)`; add the missing status to the routing — `platform-workflow` §5 | | `Error: Cannot instantiate abstract class` / "Service X not in container" | Direct `new` bypassing `handle()` (V2 / V3) | Replace with `$this->handle(X::class, ...)` from inside the node | diff --git a/skills/platform-implementation/SKILL.md b/skills/platform-implementation/SKILL.md index 0e6895b..dcbe195 100644 --- a/skills/platform-implementation/SKILL.md +++ b/skills/platform-implementation/SKILL.md @@ -115,7 +115,7 @@ Die Naht ist `protected` — laufzeit- **und** typsystemhart: Code außerhalb de | Entity Validator (BC-level) | `{BC}/Entity/Validation/Validator.php` | Generator: ForceOverwrite | Field-level validation for the BC-root persistence entity, one per BC table | | Domain Context | `Context.php` | Generator: ForceOverwrite | Generated, hermetic base class every BC facade and both Aggregate write/read facades in the domain extend — `implements GeneratedContextInterface` (the `jardissupport/contracts` marker), **no package base class**. Hosts the Kernel-Naht `handle()`/`context()` (`protected`), `resource()/payload()/version()/result()`, and the `classVersion()`/`classVersionConfig()` hooks. Sits directly under the Domain root — no `Foundation/` subdir. **Measuring stick:** this generated Context body is judged by the **Generat-Regeln in this skill** (hermetic, ForceOverwrite, never hand-edited) — not by the Closure-Orchestrator constitution (`rules-architecture` §3) that governs hand-written Jardis code | | BC Facade | `/.php` | Generator: ForceOverwrite | `extends Context`; accessors only, **no caching**. This is the Außentür (G2): exposes `->{agg}()` (returns the **read** facade `{Agg}Read` via `handle(Read::class)` — aliased `{Agg}AggregateRead` when BC and aggregate share a name) and `->process()` (returns the `Process` facade). No general write accessor — the write facade is not reachable from here. **Rules-Layer addendum (G10):** for every Command exposed in `Rules.yaml` (`bindings.{command}.expose: true`), the facade additionally carries a direct method `{lcfirst(Command)}({Cmd}DTO): DomainResponseInterface`, delegating via `$this->context({HandleClass}::class, $cmd)()` into the same generated CommandHandler — the Rule chain (Guard) runs structurally, in the dispatch, not at this door. A Create-Command can never be exposed this way (name collision with `{agg}()`, caught at build time). | -| Rule stub | `{BC}/Rule/.php` | DeveloperOwned (tag `RuleClass`) | `extends Context`. `__invoke(DTO): RuleResult` — your bestand-check logic, reading only your own BC's state (M9) — via `$this->handle({BC}::class)->{agg}()` for a facade-exposed query, or directly via `$this->context({Handler}::class, $payload)()` for a BC-internal read (e.g. the derived Selector). Merged on every rebuild by the RuleClass Header-Splice (`phpmerge.MergeRuleClass`): the file header — class docblock, `__invoke` signature, imports, `class X` line — always follows the generator, so a rewritten docblock, a renamed Command DTO or a renamed Rule reach an already-materialised stub; your `__invoke` BODY, your own properties/constants and any helper method you added stay 100% verbatim, and your own imports are merged in additively. A docblock you write directly above `__invoke` survives too — the splice starts at the signature line, so only the signature itself follows the generator. **A freshly generated, not-yet-implemented body throws** — `throw new \RuntimeException('Not implemented: write the rule predicate for ' . self::class)` — instead of returning `RuleResult::pass()` (G03, `wissensbasis/stub-ausfallphilosophie.md`, go-target-lab). A Guard-Closure never wraps its Rule dispatch in try/catch, so this propagates uncaught and surfaces through the generated Command handler's `catch (\Throwable $e)` branch as a 500 InternalError — never the 422 `RuleViolation` a bound Rule is meant to produce — until you implement the predicate. ClassVersion-fähig (`Rule/v{N}/.php`) — the **payload structure + messageKey** are the stable API across versions, only the accepted set may tighten (M5). | +| Rule stub | `{BC}/Rule/.php` | DeveloperOwned (tag `RuleClass`) | `extends Context`. `__invoke(DTO): RuleResult` — your bestand-check logic, reading only your own BC's state (M9) — via `$this->handle({BC}::class)->{agg}()` for a facade-exposed query, or directly via `$this->context({Handler}::class, $payload)()` for a BC-internal read (e.g. the derived Selector). Merged on every rebuild by the RuleClass Header-Splice — mechanics (which header line follows the generator, where the splice starts): [[beschreibt-bauweise-von::builder-generat-bauweise]] §7.2. In practice: a rewritten docblock, a renamed Command DTO or a renamed Rule reach an already-materialised stub; your `__invoke` BODY, your own properties/constants and any helper method you added stay 100% verbatim, and your own imports are merged in additively. A docblock you write directly above `__invoke` survives too. **A freshly generated, not-yet-implemented body throws** — `throw new \RuntimeException('Not implemented: write the rule predicate for ' . self::class)` — instead of returning `RuleResult::pass()` (G03, `wissensbasis/stub-ausfallphilosophie.md`, go-target-lab). A Guard-Closure never wraps its Rule dispatch in try/catch, so this propagates uncaught and surfaces through the generated Command handler's `catch (\Throwable $e)` branch as a 500 InternalError — never the 422 `RuleViolation` a bound Rule is meant to produce — until you implement the predicate. ClassVersion-fähig (`Rule/v{N}/.php`) — the **payload structure + messageKey** are the stable API across versions, only the accepted set may tighten (M5). | | RuleResult | `{BC}/Rule/Data/RuleResult.php` | Generator: ForceOverwrite | Hermetic readonly VO, named constructors `RuleResult::pass()` / `RuleResult::reject($rule, $messageKey, $context)`. No Kernel-/Contract-type — purely BC-scoped. | | Guard closure | `{BC}/Rule/Guard/Guard.php` | Generator: ForceOverwrite | Chain-of-Responsibility: runs the bound Rule chain as AND with short-circuit; dispatched from inside the generated CommandHandler at a per-handler-family anchor (Create/Set/Add: after `Validate`, before Apply/persist; Remove: after payload+Load, before Apply, since Remove has no `Validate` call). A rejection never throws — the handler builds a `RuleViolation` (422) `DomainResponseInterface` directly, payload `{rule, messageKey, context}`. | | Aggregate Write Facade | `/Aggregate//.php` | Generator: ForceOverwrite | `extends Context`. Hosts the inline Command methods + `event(): Events`; each command delegates `return $this->context(::class, $dto, $version)();`. **Family-internal only** — reached via the Kernel-Naht (`$this->handle(::class)`), never via the BC facade (G2/G3). Fully regenerated — no merge, no `@flow-id`. | @@ -128,7 +128,7 @@ Die Naht ist `protected` — laufzeit- **und** typsystemhart: Code außerhalb de | Repository tree | `{Agg}/Repository/{Repository,Query{ById,ByIds,By,…},Transform,Validate,Persist,Query{Additions,List}}.php` | ForceOverwrite | Read / write pipeline base; one root-fetch class per read variant (`WHERE pk =` / `pk IN` / `unique =` / hand-modelled); custom queries (generic CRUD = pipeline) | | Process DTO | `/Process//Command/.php` | Generator: ForceOverwrite | Input DTO for a BC-level process. A field may be a **typed Command field** (typed batch input): declared in `input.fields` as `accepts: [{Agg}.{Command}, …]` (+ optional `list: bool`), it renders as a Composition over the **same-BC** aggregate Command DTOs — a Union `A\|B $field` (single) or `public array $field` + `/** @param array */` (list), Command FQCNs pulled in via `use`. It only **references** the hermetic Command DTOs (never `extends`); no per-entry class is generated, no dispatch loop is emitted (the `foreach … match(instanceof)` is your node code). Command names are the **DTO class names** (e.g. `Counter.Counter`), not handler names. Namespace: `…\Process\\Command\`. | | Process Orchestrator | `/Process//Command/Handler/Handler.php` | Generator: ForceOverwrite | `extends Context`. **Full-graph** workflow orchestrator: `__invoke()` builds the `Workflow`, runs `$workflow($this->config(), $cmd)` over the **whole** painted graph (no IPO three-pot split anymore), harvests Domain events from the result chain (`getChain()` → `addEvent(…, EventScope::Domain)`), and returns the transformed `DomainResponse`. A private `config(): WorkflowConfigInterface` registers every node via `addNode()` (see `platform-workflow` §2). **Regenerated, not edited** — the graph is the Process Designer truth. Namespace: `…\Process\\Command\Handler\Handler`. | -| Process Node | `/Process//Command/Handler/Action/.php` | Generator: CreateIfNotExists + `@node-id` body-preserve (custom nodes + sub-process nodes) | Custom node: action stub (`__invoke(WorkflowContextInterface): WorkflowResultInterface`) — Generator writes the stub + Mini-PRD-DocBlock + an `@node-id` marker once; the body is yours and survives rebuilds via the `@node-id` body-preserve merger. **Aggregat-Aufruf-Knoten get a generated default body instead of the throw stub** at first generation, when the node declares **exactly one** same-BC aggregate-Command `consumedCalls` entry with a green drift verdict (baseline-free "declared, never built" counts as green): the body calls the aggregate's **write facade directly through the Kernel-Naht** (`$this->handle({Agg}::class)->{useCase}($cmd->{field})` — not via the BC facade, which exposes no write accessor, G2/A5), the argument taken from the single unambiguous input Command field — an `@todo`-only resolver stub covers none-or-ambiguous), routes `ON_SUCCESS`/`ON_FAIL` off `$response->isSuccess()` when the node has a painted `onFail` edge, and returns `'data' => $response->getData()`. It carries a **marker comment** ("Generierter Default-Body — gegen die Knoten-Requirements pruefen, erweitern oder ersetzen") — the KI-Implementierungs-Phase's cue that mechanics are done but domain correctness is unchecked; treat it exactly like a stub that already compiles, review it against the node's actual requirements before trusting it. Cross-BC calls, multiple calls, non-Command/non-aggregate calls, and a non-green drift verdict all fall back to the byte-identical throw stub. Sub-process node: **typed Dev-Stub** (`CreateIfNotExists` + `@node-id` body-preserve) — the Generator writes a typed `logic()` body with the real Sub-DTO field names as named constructor args, a `$this->context({SubHandler}::class, $in)()` call, status mapping (`$res->isSuccess() ? ON_SUCCESS : ON_FAIL`), and flat Domain-event bubbling; werfende Resolver pro Sub-DTO-Feld als PHPStan-gültige Platzhalter — only the DTO-field values are your code (see `platform-cookbook` §2 Recipe 8). A node flagged **Event ◇** (`mode: async`) instead is pre-filled to emit a Domain event: the Designer's `ProcessEventFieldEditor` authors `eventFields: [{label, source}]` bindings — either a process-input Command field, or a **Kollektor-Quelle** pointing at another node's aggregate-call result (`kind: latest` = that node's last run, `kind: all` = every run as a list; the leading use case is binding the post-persist id of a keyless Create) — the Generator resolves those to the affected aggregate's model identity and renders a `final readonly` event-data class under `Event/.php` with one property per resolved identity chain link plus the two automatic standard fields `eventId` (UUID7) and `occurredAt` — the node's `logic()` body is **fully generated, no hand-fillable stub** (see `platform-workflow` §Event-Kasten). Namespace: `…\Process\\Command\Handler\Action\`. | +| Process Node | `/Process//Command/Handler/Action/.php` | Generator: CreateIfNotExists + `@node-id` body-preserve (custom nodes + sub-process nodes) | Custom node: action stub (`__invoke(WorkflowContextInterface): WorkflowResultInterface`) — Generator writes the stub + Mini-PRD-DocBlock + an `@node-id` marker once; the body is yours and survives rebuilds via the `@node-id` body-preserve merger. **Aggregat-Aufruf-Knoten get a generated default body instead of the throw stub** at first generation, when the node declares **exactly one** same-BC aggregate-Command `consumedCalls` entry with a green drift verdict — what counts as green and what falls back to the throw stub (cross-BC calls, multiple calls, non-Command/non-aggregate calls, a non-green verdict): [[beschreibt-bauweise-von::builder-generat-bauweise]] §6.3. The default body calls the aggregate's **write facade directly through the Kernel-Naht** (`$this->handle({Agg}::class)->{useCase}($cmd->{field})` — not via the BC facade, which exposes no write accessor, G2/A5), the argument taken from the single unambiguous input Command field — an `@todo`-only resolver stub covers none-or-ambiguous), routes `ON_SUCCESS`/`ON_FAIL` off `$response->isSuccess()` when the node has a painted `onFail` edge, and returns `'data' => $response->getData()`. It carries a **marker comment** ("Generierter Default-Body — gegen die Knoten-Requirements pruefen, erweitern oder ersetzen") — the KI-Implementierungs-Phase's cue that mechanics are done but domain correctness is unchecked; treat it exactly like a stub that already compiles, review it against the node's actual requirements before trusting it. Sub-process node: **typed Dev-Stub** (`CreateIfNotExists` + `@node-id` body-preserve) — the Generator writes a typed `logic()` body with the real Sub-DTO field names as named constructor args, a `$this->context({SubHandler}::class, $in)()` call, status mapping (`$res->isSuccess() ? ON_SUCCESS : ON_FAIL`), and flat Domain-event bubbling; werfende Resolver pro Sub-DTO-Feld als PHPStan-gültige Platzhalter — only the DTO-field values are your code (see `platform-cookbook` §2 Recipe 8). A node flagged **Event ◇** (`mode: async`) instead is pre-filled to emit a Domain event: the Designer's `ProcessEventFieldEditor` authors `eventFields: [{label, source}]` bindings — either a process-input Command field, or a **Kollektor-Quelle** pointing at another node's aggregate-call result (`kind: latest` = that node's last run, `kind: all` = every run as a list; the leading use case is binding the post-persist id of a keyless Create) — the Generator resolves those to the affected aggregate's model identity and renders a `final readonly` event-data class under `Event/.php` with one property per resolved identity chain link plus the two automatic standard fields `eventId` (UUID7) and `occurredAt` — the node's `logic()` body is **fully generated, no hand-fillable stub** (see `platform-workflow` §Event-Kasten). Namespace: `…\Process\\Command\Handler\Action\`. | | Cross-BC-Call / Externer-Call node | `/Process//Command/Handler/Action/.php` | Generator: `CreateIfNotExists` + `@node-id` body-preserve (**same mechanism as Custom/Sub-Process nodes** — no new write mode for the Action file itself) | A node declaring `crossBcCall: {ServiceName}` (the cross-BC target stays in the node's own `consumedCalls[0]` — no duplicate facade/method field) or `externalCall: {service, level: process\|bc\|domain}` gets a **working body at first generation instead of the throw stub** — unconditionally, no drift-verdict gating (unlike the Kollektor-Quellen default-body case, because the delegation target here is always the generated Service, never an aggregate directly): `return $this->handle({Service}::class)->__invoke($context);`. The body is still `@node-id`-preserved like any other node, so you may customize it, but there is normally nothing to fill in — the business logic lives in the Service (below), not here. Mutually exclusive with `subProcess` on the same node; `mode: async` excludes all three specializations. | | Service (Closure) stub | `{Domain}/Service/.php` (Cross-BC-Call; also Externer-Call `level: domain`) · `{BC}/Service/.php` (Externer-Call `level: bc`) · `{BC}/Process/{Name}/Service/.php` (Externer-Call `level: process`, the default) | Generator: `DeveloperOwned=true` + `ForceOverwrite=false`, **header-bewusster Merge** (`ServiceClass` tag — a third write mode distinct from `@node-id` body-preserve and from `MergeDeveloperFile`) | One `__invoke()` Closure (Closure-Orchestrator, `rules-architecture` §3) per declared service name — reused across every node/process that targets the **same** thing (same Facade+Method for Cross-BC-Call, same `level` for Externer-Call; a different target is a different service, `V-SVC-TARGET-CONSISTENT`). Pre-wired scaffolding: **Cross-BC-Call** gets `$this->handle({CallerBC}::class)`/`handle({TargetBC}::class)` calls plus an **ACL docblock nudge** ("translate the response into your own vocabulary, never pass the foreign DTO through unchanged" — a Nudge, not a static enforcement); **Externer-Call** gets a PSR-18 gerüst via `$this->resource()->httpClient()` (null-guarded) plus a docblock documenting the transport-error → `ON_FAIL`/`ON_TIMEOUT` convention (and the built-in `jardisadapter/http` retry). **Cross-BC write target (G7):** when the Service call is a **write**, the target must be the foreign BC's `process()` (a modelled Process there) — never its aggregate write facade; the Designer/Validator enforce this (`V-XBC-WRITE-TARGET`, identical for UI and MCP) so a foreign-write `consumedCalls` entry only offers process methods. Reads may target the foreign `{Agg}Read` methods unrestricted. See `platform-cookbook` §2 Recipe 9 for the end-to-end DTO-translation → foreign `process()` → response-mapping pattern. Extends `{Domain}Context` like every generated behaviour class — **no constructor**. The write mode is the interesting part: on every rebuild the **class-level docblock is regenerated** (union of every declaring node's Mini-PRD description, deterministically sorted — "the docblock follows the node"), while the `__invoke()` body and any helper methods/`use`-imports you add are preserved exactly, same as a normal developer-merged file. This is the one place in the generated tree where a file's *header* is generator-owned and its *body* is dev-owned within the **same** file. The Service never appears in the generated Api-Spec. | | Dev-Code (process) | `/Process//Command/Handler/Action/.php` | Developer-owned (custom-node body) | **The primary developer surface.** Process logic lives in the custom-node bodies — orchestrator + facade are hermetic. Additional developer segments (`Query/`+`Handler/`, `Repository/`, `Service/`) are authored as needed, not generated (the two exceptions above are the *generated* Service stubs a Cross-BC-Call/Externer-Call node declares — their `__invoke()` body is still yours to fill). | @@ -137,9 +137,9 @@ Die Naht ist `protected` — laufzeit- **und** typsystemhart: Code außerhalb de - **ForceOverwrite** — rewritten on every build. The **entire** `{Agg}/` tree, Domain/BC facades, the hermetic `{BC}Process` facade, Process DTO (`Command/.php`) + orchestrator (`Command/Handler/Handler.php`) files, event-data classes (`Event/.php`), and the Rule-Layer hermetic pair `{BC}/Rule/Data/RuleResult.php` + `{BC}/Rule/Guard/Guard.php`. - **CreateIfNotExists** — written once, then preserved. `Config/.env*` files; Process custom-node stubs `{BC}/Process/{Name}/Command/Handler/Action/.php` (one file per node). -- **`@node-id` body-preserve** — Process custom nodes **and sub-process nodes**. On rebuild the Action-Class merger regenerates the node's header (DocBlock + signature) but preserves the developer body + helpers, keyed by the `@node-id` marker. For sub-process nodes: the first build emits a typed Dev-Stub (real Sub-DTO field names, `context()` call, status mapping, flat event bubbling, werfende Resolver); subsequent builds preserve the developer body unchanged. (The Process-Designer "Force" build path can deliberately overwrite a node body instead.) **Rule stubs** (`{BC}/Rule/.php`) use a related but distinct merge rule (no `@node-id` — a Rule has no single declaring node): on rebuild, every generated method with a name match is checked individually — an **untouched** method migrates wholesale to the new signature/body, a **hand-edited** method is kept 100% verbatim (including its docblock). This per-method Unberührt-Check applies to **every** generated method on a DeveloperOwned class, not just `__invoke`. +- **`@node-id` body-preserve** — Process custom nodes **and sub-process nodes**. On rebuild the developer body + helpers are preserved, keyed by the `@node-id` marker (merge mechanics: [[beschreibt-bauweise-von::builder-generat-bauweise]] §7.2). For sub-process nodes: the first build emits a typed Dev-Stub (real Sub-DTO field names, `context()` call, status mapping, flat event bubbling, werfende Resolver); subsequent builds preserve the developer body unchanged. (The Process-Designer "Force" build path can deliberately overwrite a node body instead.) **Rule stubs** (`{BC}/Rule/.php`) use a related but distinct, per-method merge rule (no `@node-id` — a Rule has no single declaring node; same reference for mechanics): an **untouched** generated method migrates wholesale to the new signature/body, a **hand-edited** method is kept 100% verbatim (including its docblock) — for every generated method on a DeveloperOwned class, not just `__invoke`. -**Aggregate facade mechanics.** The aggregate's public surface for reads is `{Agg}/{Agg}Read.php` (`extends Context`), reached via `$bc->{agg}()` → `handle(Read::class)` — the Außentür (G2), public FQN `\\Aggregate\\Read`. The write facade `{Agg}/{Agg}.php` (public FQN `\\Aggregate\\`) hosts the commands + `event()` and is reached only family-internally via the Kernel-Naht, `$this->handle(::class)` — never through `$bc->{agg}()`. Both facades are fully regenerated (ForceOverwrite) — there is no body-merge, no `@flow-id`. You never add methods to either; new aggregate behaviour is modelled in the Designer (structure) or authored as a Process (behaviour). ClassVersion resolves a versioned variant `\\Aggregate\\v{N}\` (write) or `…\v{N}\Read` (read) when `version()` is active (see `platform-versioning`). +**Aggregate facade mechanics.** The aggregate's public surface for reads is `{Agg}/{Agg}Read.php` (`extends Context`), reached via `$bc->{agg}()` → `handle(Read::class)` — the Außentür (G2), public FQN `\\Aggregate\\Read`. The write facade `{Agg}/{Agg}.php` (public FQN `\\Aggregate\\`) hosts the commands + `event()` and is reached only family-internally via the Kernel-Naht, `$this->handle(::class)` — never through `$bc->{agg}()`. Both facades are fully regenerated (ForceOverwrite) — there is no body-merge, no `@flow-id`. You never add methods to either; new aggregate behaviour is modelled in the Designer (structure) or authored as a Process (behaviour). ClassVersion resolves a versioned variant `\\Aggregate\\v{N}\` (write) or `…\v{N}\Read` (read) when the per-call `$version` argument is non-empty (see `platform-versioning`). **Read base — every aggregate's read facade (`{Agg}Read`) carries the same uniform read API** (hermetic, emitted on every build, no switch): @@ -176,7 +176,7 @@ The aggregate tree is hermetic; **nothing** under `{Agg}/` is a developer overri |---|---| | Add or change **behaviour** (validation, calculation, cross-aggregate coordination, event transport, external calls) | Model a **Process** in the Process Designer. Generator emits the DTO + orchestrator + node skeleton under `{BC}/Process/{Name}/Command/` and `Command/Handler/Action/` (plus `Event/` for event nodes), and a thin-dispatch method on the hermetic `{BC}Process` facade (`$bc->process()`). Your logic lives in the custom-node bodies (`@node-id` preserve). Additional segments (`Query/`, `Repository/`, `Service/`) are authored by the developer as needed. A process has **no aggregate ownership** and may coordinate several aggregates. | | Change the aggregate's **structure / invariants** (entities, relations, keys, `required`/`depend` rules, commands/queries) | Re-model the aggregate in the Aggregate Designer (`Aggregate.yaml`) and rebuild. The generated tree changes accordingly. | -| Provide a **versioned variant** of a generated class (tenant / feature-flag) | Put the variant under `{Agg}/.../v{N}/.php` and activate via `version()` on the Domain facade. ClassVersion (`LoadClassFromSubDirectory`) resolves `…\v{N}\` before the baseline. **Note:** version *creation* is not wired by any generator today — the *resolution* is. See `platform-versioning`. | +| Provide a **versioned variant** of a generated class (tenant / feature-flag) | Put the variant under `{Agg}/.../v{N}/.php` and select it **per call** via the `$version` argument threaded through every facade method — there is no domain-wide default (the Domain facade is `final`, `{Domain}Context` hermetic). ClassVersion (`LoadClassFromSubDirectory`) resolves `…\v{N}\` before the baseline. **Note:** version *creation* is not wired by any generator today — the *resolution* is. See `platform-versioning`. | | Guard a Command with a **business rule / bestand-check** before it runs (Rules-Layer) | Declare a catalog entry + endpoint binding in `Rules.yaml` (BC-level, sibling of Process/Steckbrief). Generator emits a Rule stub under `{BC}/Rule/{RuleName}.php` (`__invoke({Cmd}DTO): RuleResult`) plus a hermetic Guard closure that runs the bound chain (AND, short-circuit) ahead of the Command. Your logic lives in the Rule stub body — bestand-checks stay within your **own BC** (M9), via the read facade or directly via the Kernel-Naht (`context()`) for a BC-internal read; anything needing a cross-BC read or a multi-step/side-effecting flow is Prozess-Territorium, not a Rule. **Until you implement it, the stub throws** (500), it does not fail open — see the Rule stub row in §1 and `platform-cookbook` §3 Troubleshooting. | **Migration note (path collision).** In the pre-Platform-removal layout a file directly under `{Agg}/Command/…` was a **shadow override** that the ClassVersion reader (`segmentNames: ['', 'Platform']`) found *before* the generator class under `{Agg}/Platform/Command/…`. The Generator writes to `{Agg}/Command/…` itself (ForceOverwrite) — an old baseline override at that path **collides** and is overwritten. There is no auto-migration. To migrate such an override: move the behaviour into a Process (`{BC}/Process/`), or re-express it as a `v{N}/` versioned override, or re-model in the Designer. The build deletes the orphan `{Agg}/Platform/` directory on regen. @@ -229,7 +229,7 @@ Every row resolves to a **Process** (the developer surface), a **Designer re-mod | Emit / route a domain event to a transport (Kafka, webhook, …) | Author the transport in a Process node that runs after the aggregate command — see `platform-cookbook` event-transport recipe. The generated `EventRouter` is hermetic. | Adapter (at process level) | | Remove an entity with a NOT-NULL `depend:` parent | **Not allowed** — the Build rejects Remove on an entity another entity depends on via NOT-NULL FK (F3.6 / V8-sibling). Re-model the aggregate to drop the NOT-NULL constraint, or model a soft-delete Process so dependents stay resolvable. | Designer + Process | | Remove a `required` child (no `Remove…` command exists, or the last entry refuses to delete) | **By design** — a relation marked `required` (default; opt-out `required: false`) is an existence invariant. **`required·one`** emits **no `Remove…`** (only `Set…`); **`required·many`** keeps `Add…`/`Remove…(id)` but the Remove handler carries a **min-guard** that throws when count would hit 0 (Create also demands ≥1, `Count::min(1)`). To allow emptying, re-model as `required: false` and rebuild. | Designer (Aggregate.yaml) | -| Tenant- / feature-flag variant of any generated class | Put the variant under `{Agg}/.../v{N}/.php` and activate via `version()` on the Domain facade (see `platform-versioning`). Resolution is wired; creation is out of current scope. | ClassVersion | +| Tenant- / feature-flag variant of any generated class | Put the variant under `{Agg}/.../v{N}/.php` and select it **per call** via the `$version` argument — no domain-wide default (see `platform-versioning`). Resolution is wired; creation is out of current scope. | ClassVersion | ### 5. New-code locations — everything developer-owned lives under `{BC}/Process/` diff --git a/skills/platform-usage/SKILL.md b/skills/platform-usage/SKILL.md index fa4c836..59c1d19 100644 --- a/skills/platform-usage/SKILL.md +++ b/skills/platform-usage/SKILL.md @@ -37,7 +37,7 @@ Matching BC/aggregate names (`MeterDevice\Counter\Counter`) → two `->counter() ### 2. Bootstrap lifetime -The Koffer (`$kernel: DomainKernelInterface`) is a plain immutable value object built once by the Bootstrap-Packer (`JardisCore\Kernel\Bootstrap\BuildDomainKernelFromEnv`, typically invoked from the generated one-time `App/bootstrap.php`, `core-kernel`) — there is no static `$sharedRegistry` and no `DomainApp`/`ServiceRegistry`. `MyApp` (and every BC facade) is a cheap, uncached `new` — safe to construct fresh on every access. +The Koffer (`$kernel: DomainKernelInterface`) is a plain immutable value object built once by `BuildDomainKernelFromEnv`, typically invoked from the generated one-time `App/bootstrap.php` — there is no static `$sharedRegistry` and no `DomainApp`/`ServiceRegistry`. Bootstrap internals (what `BuildDomainKernelFromEnv` wires, ENV shape): s. Skill `core-kernel`. `MyApp` (and every BC facade) is a cheap, uncached `new` — safe to construct fresh on every access. | Transport | Koffer (`$kernel`) lifetime | `MyApp` lifetime | Note | |---|---|---|---| @@ -63,7 +63,7 @@ Tenancy still matters at the adapter level: build a fresh Koffer (fresh DB crede | 422 | 422 Unprocessable Entity | 2 | Rules-Layer: a bound business Rule rejected the Command — payload carries `{rule, messageKey, context}` under `data` (requires `jardiscore/kernel` ≥ 1.1.0); map `messageKey` to a localized message in this transport layer, never in the domain | | 500 | 500 Internal | 1 | Exception escaped the pipeline (incl. a technical failure inside a Rule's bestand-check — never a 422) | -Envelope from `getStatus()` / `getData()` / `getErrors()` / `getMetadata()` (plus `isSuccess()` shortcut). Per X-1 the generator emits a minimal payload — **Command** echoes only the affected identifier, **Query** returns the projected scalar tree under the aggregate root key (see `platform-cookbook` for the full response-shape table). Examples: +Envelope from `getStatus()` / `getData()` / `getErrors()` / `getMetadata()` (plus `isSuccess()` shortcut). Per X-1 the generator emits a minimal payload — **Command** echoes only the affected identifier, **Query** returns the projected scalar tree under the aggregate root key; which fields the generator derives per use-case: [[beschreibt-bauweise-von::builder-generat-bauweise]] §5.2 (see also `platform-cookbook` for the full response-shape table). Examples: ```json // Command (e.g. CreateCounter) → 201 @@ -89,7 +89,7 @@ CQRS: the Command response carries only identity — to get full state after a w > **Process input DTO name:** the examples below assume a Process `CreateCounter` was modelled around the `Counter` aggregate's `createCounter` command (`platform-implementation` §2/§4) — its input DTO lives at `{BC}/Process/CreateCounter/Command/CreateCounter.php` (namespace `…\Process\CreateCounter\Command`), distinct from the aggregate's own Command DTO of the same simple name. Constructor fields like `name`/`obis` are **illustrative**, domain-specific to this example — not a fixed API; the real fields come from your process's own `input.fields`. -**Optional ready-made HTTP delivery (`jardiscore/app`).** This skill stays transport-agnostic by design (§1/§6) — the hand-rolled `JsonResponse` examples below work for any PSR-15 framework and remain valid. For HTTP specifically, `jardiscore/app` (skill `core-app`) is a ready-made alternative to hand-rolling this wiring yourself: a `Router`/`Routes` pair (FastRoute behind `Contract\RouterInterface`), a PSR-15 middleware pipeline, and `Handler\Response\MapDomainResponse` — the canonical `DomainResponseInterface` → PSR-7 `{status,data,errors,meta}` envelope mapper. It maps every one of the 11 `ResponseStatus` cases to its HTTP equivalent, with two documented deviations: `204` returns a bare empty response (no body, no `Content-Type`), and `422` passes `getData()` through unreshaped. It is optional, not a requirement: CLI, queue, and worker transports (below) have no equivalent package and stay hand-rolled either way. +**Optional ready-made HTTP delivery (`jardiscore/app`).** This skill stays transport-agnostic by design (§1/§6) — the hand-rolled `JsonResponse` examples below work for any PSR-15 framework and remain valid. For HTTP specifically, `jardiscore/app` is a ready-made alternative to hand-rolling this wiring yourself: routing, a PSR-15 middleware pipeline, and the canonical `DomainResponseInterface` → PSR-7 `{status,data,errors,meta}` envelope mapper. Pipeline internals (router/middleware wiring, the mapper's class): s. Skill `core-app`. What the transport author must know regardless of internals: the mapper covers every one of the 11 `ResponseStatus` cases, with two documented deviations — `204` returns a bare empty response (no body, no `Content-Type`), and `422` passes `getData()` through unreshaped. It is optional, not a requirement: CLI, queue, and worker transports (below) have no equivalent package and stay hand-rolled either way. **HTTP (PSR-15) — write via process():** diff --git a/skills/platform-versioning/SKILL.md b/skills/platform-versioning/SKILL.md index 1b6fc41..3eb30de 100644 --- a/skills/platform-versioning/SKILL.md +++ b/skills/platform-versioning/SKILL.md @@ -9,36 +9,24 @@ next: [] ### 1. ClassVersion resolution — Platform-free, per-class `v{N}` -The aggregate tree is hermetic and **Platform-free** (no `Platform/` segment in path or namespace). Resolution does not walk a `['', 'Platform']` two-segment chain — the **Generator emits a `classVersion()` override** in the generated `Context.php` base class that wires the reader `LoadClassFromSubDirectory`: - -```php -class MeterDeviceContext implements GeneratedContextInterface -{ - protected function classVersion(): ClassVersionInterface - { - $config = $this->classVersionConfig(); - - return new ClassVersion( - $config, - new LoadClassFromSubDirectory($config), - cache: new ClassResolutionCache(), - ); - } -} -``` +The aggregate tree is hermetic and **Platform-free** (no `Platform/` segment in path or namespace). Resolution does not walk a `['', 'Platform']` two-segment chain — the **Generator emits a `classVersion()` override** in the generated `Context.php` base class that wires the reader `LoadClassFromSubDirectory`. Emitted code + wiring: +[[beschreibt-bauweise-von::builder-generat-bauweise]] §4.1. **No domain-wide version default:** the Domain facade (`.php`) is `final` and JardisCore-free (holds only a `DomainKernelInterface` Koffer) — it offers no override surface, and `Context` (which hosts `classVersion()`/`classVersionConfig()`, `platform-implementation` §1) is hermetic (never hand-edited). A domain-wide default `version()` hook does not exist — the only lever is the per-call `$version` argument threaded through every facade method (see below). -> **Proxy first.** The wired `ClassVersion` (no `proxyClassFinder` passed above ⇒ Ctor default `LoadClassFromProxy`) consults the **proxy cache first** and only falls to `LoadClassFromSubDirectory` when the proxy returns `null` (`support/classversion/src/ClassVersion.php:62-70`). A generated Domain has no proxy config, so the proxy yields `null` and the SubDirectory resolution below is what runs in practice. +> **Proxy first, but a no-op here.** The wired `ClassVersion` consults the proxy cache before the +> SubDirectory reader (order + internals: [[beschreibt-bauweise-von::builder-generat-bauweise]] +> §4.2) — a generated Domain has no proxy config, so in practice the SubDirectory resolution below +> is what runs. -**How `LoadClassFromSubDirectory` resolves** `$this->context(::class, $dto, $version)` / `$this->handle(::class)`: +**How `LoadClassFromSubDirectory` resolves** `$this->context(::class, $dto, $version)` / `$this->handle(::class)` — full step-by-step: [[beschreibt-bauweise-von::builder-generat-bauweise]] §4.2. What matters for placing your override: -1. It **injects the version before the last namespace segment** (the class name), per class: +1. The version is **injected before the last namespace segment** (the class name), per class: `…\Command\Handler\CreateCounter` + `v2` → `…\Command\Handler\v2\CreateCounter`. The `v2/` subdir is the class's **immediate neighbour** — not an aggregate-root `v2/` tree. 2. The version comes from the `$version` argument threaded through the facade method (`string $version = ''`, verified on `{Agg}/{Agg}.php`). Empty version → **no version subdir is tried**; resolution falls straight to the baseline. -3. With a non-empty version: if a `ClassVersionConfig` is present, the reader expands `config->fallbackChain($version)` (e.g. `v3 → [v3, v2, v1]`) and tries each injected class in order; without config it tries just `[$version]`. -4. **First `class_exists` wins.** If no versioned class exists, it falls back to the **baseline** `$className` — i.e. the generated class itself. If even that is missing, `InvalidArgumentException`. +3. With a non-empty version and a `ClassVersionConfig` present, the fallback chain is tried in order (e.g. `v3 → [v3, v2, v1]`); without config only `[$version]`. +4. First match wins; missing versioned class → falls back to the **baseline** (the generated class itself); missing even that → `InvalidArgumentException`. ``` Baseline (the generated, hermetic class — always present): diff --git a/skills/platform-workflow/SKILL.md b/skills/platform-workflow/SKILL.md index 8e89751..a9eb4dc 100644 --- a/skills/platform-workflow/SKILL.md +++ b/skills/platform-workflow/SKILL.md @@ -27,7 +27,7 @@ User-Code laesst `handlerFqcn` immer `null` — die Engine stamped es via `Workf ### 2. `WorkflowConfig` im `config()`-Body -Der Generator emittiert eine private `config(): WorkflowConfigInterface`, die **jeden** Knoten des gemalten Graphen via `addNode()` registriert (Start zuerst, in topologischer Reihenfolge; ein End-Knoten als `addNode(X::class, [])`). Routing ist eine `[WorkflowResult::ON_* => NextNode::class]`-Map pro Knoten — die Engine laeuft die gemalten Kanten, exklusive Zweige laufen exklusiv. Hand-edits am Orchestrator (z.B. zusaetzliche Transition) folgen demselben Muster: +Der Generator emittiert eine private `config(): WorkflowConfigInterface`, die **jeden** Knoten des gemalten Graphen via `addNode()` registriert — Registrierungsreihenfolge und warum ein Endknoten trotzdem als `addNode(X::class, [])` erscheinen muss: [[beschreibt-bauweise-von::builder-generat-bauweise]] §6.1. Routing ist eine `[WorkflowResult::ON_* => NextNode::class]`-Map pro Knoten — die Engine laeuft die gemalten Kanten, exklusive Zweige laufen exklusiv. Hand-edits am Orchestrator (z.B. zusaetzliche Transition) folgen demselben Muster: ```php private function config(): WorkflowConfigInterface @@ -55,30 +55,9 @@ End-Knoten (leere Routing-Map `[]`) lassen die Engine ordentlich beenden. ### Event-Kasten ◇ (Event-Knoten) -Ein Designer-Knoten kann statt **Action** als **Event ◇** markiert sein (`mode: async`). Im Designer deklariert der Autor am Knoten eine **Event-Feld-Bindung** — eine Liste `eventFields: [{label, source}]`, wobei jede `source` auf ein Command-Feld des Prozess-Inputs zeigt (`ProcessEventFieldEditor.svelte`, Details-Tab des Ticket-Panels). Der Generator loest daraus die **modell-aufgeloeste Identitaet** des betroffenen Aggregats auf (ganze Vorfahren-Kette, eine Property je Kettenglied, `{label}{EntityName}Id`) und erzeugt zweierlei: +Ein Designer-Knoten kann statt **Action** als **Event ◇** markiert sein (`mode: async`). Im Designer deklariert der Autor am Knoten eine **Event-Feld-Bindung** — eine Liste `eventFields: [{label, source}]`, wobei jede `source` auf ein Command-Feld des Prozess-Inputs zeigt (`ProcessEventFieldEditor.svelte`, Details-Tab des Ticket-Panels). Was der Generator daraus baut (Identitätsauflösung, die erzeugte Event-Daten-Klasse, der vollständig generierte Knoten-Body, Union-/Listen-Quellen): [[beschreibt-bauweise-von::builder-generat-bauweise]] §6.4. -1. eine **`final readonly` Event-Daten-Klasse** unter `{BC}/Process/{Name}/Event/.php` (hermetisch, ForceOverwrite — der Knotenname ist der Event-Klassenname) mit einer Property je aufgeloester Identitaets-Kette **plus** zwei automatischen Standardfeldern: `eventId` (UUID7) und `occurredAt` (`\DateTimeImmutable`); -2. einen **Knoten**, dessen `logic()`-Body **vollstaendig generiert** ist (kein Hand-auszufuellender Stub mehr) — die Identitaetswerte werden direkt von den public-readonly-Properties des gebundenen Command-DTOs gelesen, die `eventId` per `Identity`-Service (`generateUuid7()`) erzeugt: - -```php -protected function logic(FlagReading $cmd, WorkflowContextInterface $context): array -{ - return [ - 'status' => WorkflowResult::ON_SUCCESS, - 'data' => [ - EventScope::Domain->value => [ - new ReadingFlagged( - flaggedCounterId: $cmd->affectedCounter->counterIdentifier, - eventId: $this->handle(Identity::class)->generateUuid7(), - occurredAt: new \DateTimeImmutable(), - ), - ], - ], - ]; -} -``` - -Eine **Union-Quelle** (die Bindung zeigt auf ein Command-Feld mit mehreren `accepts:`-Zweigen) rendert einen `match(true)`/`instanceof`-Dispatch je Zweig statt eines Direkt-Lesens; eine `list:true`-Quelle rendert `array_map` ueber die Liste. Der Orchestrator erntet alle so abgelegten Domain-Events nach dem Lauf aus der Kette (`getChain()`) und haengt sie via `addEvent(…, EventScope::Domain)` an die Response. **Es gibt keine Dev-Aufgabe am generierten Knoten-Body** — die einzige Autoren-Tätigkeit ist die Feld-Bindung **im Designer**, nicht im Code. Regeln fürs Binden (V-EVT-*): mind. eine Bindung, Quelle muss identitätstragend sein, keine Namenskollision, Union-Zweige gleiche Kettentiefe. Publikation nach Commit ist Sache des Aufrufers (Event-Transport-Rezepte: `platform-cookbook` §1). +**Es gibt keine Dev-Aufgabe am generierten Knoten-Body** — die einzige Autoren-Tätigkeit ist die Feld-Bindung **im Designer**, nicht im Code. Regeln fürs Binden (V-EVT-*): mind. eine Bindung, Quelle muss identitätstragend sein, keine Namenskollision, Union-Zweige gleiche Kettentiefe. Publikation nach Commit ist Sache des Aufrufers (Event-Transport-Rezepte: `platform-cookbook` §1). ### 3. handlerFactory-Closure @@ -109,16 +88,11 @@ In allen drei Faellen erhaelt der Aufrufer den vollstaendigen `WorkflowContext` ### 6. `responseStatus` und die Statusableitung am Lauf-Ende Der Knoten-Body legt zusätzlich `'responseStatus' => $response->getStatus()` in seine -Rückgabe-Map (neben `status`/`data`); der generierte `__invoke`-Wrapper faltet den Schlüssel in -`$result['data']` — beides Generator-Emission, kein Engine-Verhalten. Der Prozess-Handler scannt -nach dem Lauf die Kette (`getChain()`) zweistufig, um den nach außen gemeldeten Antwortstatus zu -bestimmen: - -1. **Erster Pass:** je Knoten-Identität (`getHandlerFqcn()`) wird nur der `responseStatus` der - **letzten** Ausführung gemerkt. -2. **Zweiter Pass:** das erste 4xx-Kettenglied, dessen Knoten laut erstem Pass **zuletzt - ebenfalls 4xx** war, geht an `transform($this->result(), $status)`. Kein 4xx in der Kette → - `transform()` ohne Status (heutiges Verhalten). +Rückgabe-Map (neben `status`/`data`); beides Generator-Emission, kein Engine-Verhalten. Der +zweistufige Kettenscan, mit dem der Prozess-Handler daraus nach dem Lauf den nach außen +gemeldeten Antwortstatus bestimmt (nur die letzte Ausführung je Knoten-Identität zählt, damit ein +geheilter Retry seinen frühen Fehlschlag nicht mehr trägt): [[beschreibt-bauweise-von::builder-generat-bauweise]] +§5.4. Das ist eine Verfeinerung der Drei-Ebenen-Trennung aus §1: **Verzweigung** (`ON_SUCCESS`/`ON_FAIL` = true/false) bleibt unverändert reine Wegwahl; **Antwort-Status** kommt weiterhin immer aus der diff --git a/skills/rules-patterns/SKILL.md b/skills/rules-patterns/SKILL.md index 26f2b00..664c9a8 100644 --- a/skills/rules-patterns/SKILL.md +++ b/skills/rules-patterns/SKILL.md @@ -9,7 +9,7 @@ next: [] ## Scope -Applies to Jardis packages (`JardisAdapter/*`, `JardisSupport/*`, `JardisTools/*`). Generated Domain code already applies most patterns for you (Facade on `.php`, Repository for persistence, Factory/Strategy inside `handle()`). Phase-3 extensions most often use **Value Object**, **Decorator** (via `v2` overrides), **Adapter** (via Domain Services). The gate in §3 applies to all. +Applies to Jardis packages (`JardisAdapter/*`, `JardisSupport/*`, `JardisTools/*`). Generated Domain code already applies most patterns for you (Facade on `.php`, Repository for persistence, Factory/Strategy inside `handle()`) — how the generator itself enforces these: [[beschreibt-bauweise-von::builder-generator-registry-bauweise]] §9. Phase-3 extensions most often use **Value Object**, **Decorator** (via `v2` overrides), **Adapter** (via Domain Services). The gate in §3 applies to all. ### 1. Pattern catalogue