trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
//! Instruction package — the sectioned-JSON source format for the PM prompt.
//!
//! Why: the PM system prompt is composed today by concatenating four
//! `include_str!` markdown assets plus a handful of project overrides
//! ([`crate::core::instruction_pipeline`],
//! [`crate::core::instruction_overrides`]). That shape has no place to record
//! *who may override what*, and it has already shipped two composition bugs
//! that a typed source format makes structurally hard to repeat: the delivered
//! prompt named 8 agents because a computed 42-agent roster was dropped at the
//! final composition step (#4196), and the non-overridable floor could not be
//! reasoned about per-unit (#4194). Epic #4183 replaces the concatenated blobs
//! with one JSON package; this module is the schema half of that work (#4184).
//!
//! What: the [`InstructionPackage`] type and its JSON schema
//! (`content/instructions/instruction-package.schema.json`, runtime content
//! since #9012). A package has two arrays:
//!
//! * `sections` — the closed ten-member taxonomy (six content sections plus
//!   the four floor sections: three absorbed from BASE_PM, plus the
//!   delegation-enforcement tables split out of `core` by #4573; autonomy was
//!   split out of `core` the same way by #8361) carrying the
//!   `customization_tier` axis. Declaration order is fixed and canonical; it
//!   has **no** effect on composed bytes.
//! * `blocks` — the ordered composition stream. Output is `blocks` in array
//!   order, and nothing else. A section may own several non-contiguous blocks,
//!   which is what lets a section be lifted out of the middle of a legacy asset
//!   without moving a byte.
//!
//! Determinism (the #4186 acceptance constraint — the composed prompt must be
//! byte-identical for the same package and roster):
//!
//! * order comes from `Vec`, never from a map — no hash iteration order can
//!   reach the output;
//! * every inter-block boundary is an explicit [`Join`] literal, so whitespace
//!   is declared rather than inferred;
//! * [`InstructionPackage::compose`] is pure — no clock, env, filesystem or
//!   randomness — so it is a total function of `(package, inputs)`;
//! * dropping content requires an explicit `optional: true`; anything else that
//!   would render empty is a hard error, so a roster can never be silently lost
//!   the way #4196 lost it.
//!
//! Strictness, and why it beats forward compatibility here. Every object in the
//! deserialization path carries `deny_unknown_fields`, so an unrecognised key is
//! a named parse error rather than dropped instruction content. The usual
//! argument against that — an older binary should be able to read a newer
//! package — does not apply to this format, because `Self::validate` gates on
//! [`SCHEMA_VERSION`] *before* any field is inspected: a package written against
//! a later schema is already rejected outright. Lenient field handling could
//! therefore only matter for a change that added a field while leaving
//! `schema_version` at 1, and that is exactly the case that must not be
//! tolerated — the old binary would compose a prompt missing the new field's
//! effect, silently, and report success. Since this artifact *is* the
//! instruction set for every PM and agent, a loud "unsupported schema_version"
//! is strictly better than a quiet, subtly wrong system prompt.
//!
//! The resulting evolution policy, which #4185/#4186 and any later schema work
//! must follow: **any field addition that can change composed bytes bumps
//! [`SCHEMA_VERSION`]**. Strict rejection is what makes that policy enforceable
//! rather than advisory.
//!
//! Version history:
//!
//! | version | change | ticket |
//! |---|---|---|
//! | 1 | initial format: `text` and `generated` bodies | #4184 |
//! | 2 | adds the `file` body kind | #4318 |
//!
//! v2 exists because #4318 made the bundled package an *authored JSON artifact*
//! rather than a Rust literal, and inlining every section's prose would have
//! turned `sections/core.md` into a single 23 KB JSON line. A `file` body names a
//! markdown source instead; it resolves through the package's
//! [`InstructionPackage::sources`], which #9012 fills from runtime content, and
//! a name with no source is [`ValidationError::UnknownFileSource`]. The bump is mandatory
//! under the policy above: a v1 build reading a v2 manifest would reject the
//! unknown `kind`, which is the correct loud failure.
//!
//! Floor block ordering is deliberately *not* constrained to canonical section
//! order — see `validate_floor_is_last` for the asset evidence.
//!
//! Scope: this module defines and validates the shape. It authors no
//! instruction content (#4185) and re-sources no build (#4186) — nothing in the
//! session-launch path calls it yet.
//!
//! Test: `instruction_package_tests.rs`.

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

/// The schema major version this build implements.
///
/// Why: a package written against a future schema must be rejected outright
/// rather than partially interpreted — a half-understood instruction package is
/// a silently truncated system prompt.
/// What: the integer matched against [`InstructionPackage::schema_version`].
/// Test: `rejects_unsupported_schema_version`.
pub const SCHEMA_VERSION: u32 = 2;

/// Where the JSON Schema document describing this format lives, relative to
/// the content bundle's `instructions/` (#9012: runtime content, no longer
/// compiled in).
///
/// Why: the schema is the artifact other tools (editors, validators) consume;
/// the tests read it back so it cannot drift from the Rust types silently.
/// Test: `schema_enums_match_rust_enums`, `schema_example_deserializes_validates_and_round_trips`.
pub const SCHEMA_PATH: &str = "instruction-package.schema.json";

/// The closed instruction section taxonomy.
///
/// Why: a closed enum makes the taxonomy reviewable and makes "every section is
/// accounted for" a compile-time-shaped question rather than a grep. Unknown
/// ids fail deserialization loudly instead of being dropped.
/// What: the six content sections (Core, Autonomous Execution, Memory, Search,
/// Workflow, Agent Delegation) plus the four floor sections — the three absorbed
/// from BASE_PM by #4183 (Identity, Non-Overridable Rules,
/// Framework-Guaranteed Conventions) and Enforcement, split out of Core by
/// #4573. Autonomous Execution was split out of Core the same way by #8361.
///
/// Documented placement of each absorbed BASE_PM block:
///
/// | `BASE_PM.md` block | section |
/// |---|---|
/// | `## Identity` | [`SectionId::Identity`] |
/// | `## Non-Overridable Rules` | [`SectionId::NonOverridableRules`] |
/// | `## Customizing PM Behavior` | [`SectionId::NonOverridableRules`] |
/// | `## Trusty Tool Priority (Non-Overridable)` | [`SectionId::NonOverridableRules`] |
/// | `## Framework-Guaranteed Conventions (Non-Overridable)` | [`SectionId::FrameworkGuaranteedConventions`] |
///
/// Since #8533 every section but [`SectionId::Core`] is tier `project`; what no
/// override removes is the safety core
/// ([`crate::core::instruction_safety_core::SAFETY_CORE`]), not a floor of
/// whole sections.
///
/// Test: `canonical_order_is_sorted_and_complete`, `schema_enums_match_rust_enums`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
// #8533: new sections must not be a major-version break for downstream matches.
#[non_exhaustive]
pub enum SectionId {
    /// Absorbed BASE_PM `## Identity` — who the PM is. Opens the prompt; an
    /// `IDENTITY` override replaces it in place (#8533).
    Identity,
    /// The safety core — the only tier-`fixed` section (#8533): Memory &
    /// Instruction Sources and the Customization Surface.
    Core,
    /// The PM allowlist. Split out of [`SectionId::Core`] by #8533.
    PmAllowlist,
    /// Delegation mechanics. Split out of [`SectionId::Core`] by #8533.
    DelegationMechanics,
    /// Agent routing and delegating well. Split out of [`SectionId::Core`] by #8533.
    AgentRouting,
    /// Parked-subagent re-engagement. Split out of [`SectionId::Core`] by #8533.
    SubagentReEngagement,
    /// The 5-phase workflow summary. Split out of [`SectionId::Core`] by #8533.
    Phases,
    /// The QA verification gate. Split out of [`SectionId::Core`] by #8533.
    QaGate,
    /// The git file-tracking protocol. Split out of [`SectionId::Core`] by #8533.
    GitFileTracking,
    /// Tickets, PRs and releases. Split out of [`SectionId::Core`] by #8533.
    TicketsPrsReleases,
    /// Messages, reports, sessions, prose style and clickable references.
    /// Split out of [`SectionId::Core`] by #8533.
    MessagesReportsSessions,
    /// When the PM runs without stopping, and when it may stop and ask.
    ///
    /// Was prose inside [`SectionId::Core`] until #8361. `core` is tier `fixed`,
    /// so no project could set its own comfort level: on a session resume the PM
    /// read one rule telling it to continue and a skill telling it to confirm,
    /// with no override surface to settle the two. Its own tier-`project`
    /// section gives a project the `AUTONOMOUS-EXECUTION` marker instead.
    AutonomousExecution,
    /// Memory protocol guidance.
    Memory,
    /// Code/architecture search guidance.
    Search,
    /// The phase workflow (today's `WORKFLOW.md`).
    Workflow,
    /// Delegation routing — dynamic, built from the deployed-agent roster.
    AgentDelegation,
    /// The canonical Prohibitions and Circuit Breakers tables. Tier `fixed`
    /// from #4573 until #8533 made it tier `project`.
    ///
    /// These two tables ARE the PM's delegation-enforcement authority, and they
    /// shipped inside [`SectionId::Core`] at tier `project` — so a three-line
    /// `CORE` block in any project's `CLAUDE.md` deleted both and left the floor
    /// asserting that a table no longer in the prompt was binding. Splitting them
    /// out as their own floor section (rather than making the whole of `core`
    /// fixed) keeps response format, prose style and project context
    /// project-customizable while putting the authority model out of reach of
    /// every override tier.
    Enforcement,
    /// Absorbed BASE_PM non-overridable rules + customization contract + the
    /// Trusty tool-priority mandate. Tier `project` since #8533; the name is
    /// historical.
    NonOverridableRules,
    /// Absorbed BASE_PM framework-guaranteed conventions (attribution footer,
    /// documentation proportionality, ticket attribution). Tier `project` since
    /// #8533.
    FrameworkGuaranteedConventions,
}

impl SectionId {
    /// Every section id, in canonical declaration order.
    ///
    /// Why: `sections` must be declared in this order so a package manifest
    /// reads the same way in every project; the order is also the enum's `Ord`,
    /// so the check is a simple sortedness test.
    /// What: the nineteen ids, in prompt order of their first block.
    /// Test: `canonical_order_is_sorted_and_complete`.
    pub const CANONICAL: [SectionId; 19] = [
        SectionId::Identity,
        SectionId::Core,
        SectionId::PmAllowlist,
        SectionId::DelegationMechanics,
        SectionId::AgentRouting,
        SectionId::SubagentReEngagement,
        SectionId::Phases,
        SectionId::QaGate,
        SectionId::GitFileTracking,
        SectionId::TicketsPrsReleases,
        SectionId::MessagesReportsSessions,
        SectionId::AutonomousExecution,
        SectionId::Memory,
        SectionId::Search,
        SectionId::Workflow,
        SectionId::AgentDelegation,
        SectionId::Enforcement,
        SectionId::NonOverridableRules,
        SectionId::FrameworkGuaranteedConventions,
    ];

    // `is_floor()` was deleted by #4286. It named the four sections nothing could
    // override. The owner ruling removed that concept: a project owns its own
    // `CLAUDE.md`, so a framework floor was the appearance of a control rather
    // than a control. `CORE` is now the single protected section, expressed by
    // its `customization_tier` alone — see `validate` and `CustomizationTier`.
}

/// Who may override a section, by increasing permissiveness.
///
/// Why: the override permission axis is the whole point of the sectioned
/// format; encoding it as a type makes "can this be overridden, and by whom?" a
/// question with one answer instead of a per-file convention.
/// What: `Fixed < Project < User` (SPEC-PMINSTR-01 §4b). `Project` deliberately
/// excludes user-tier override so one operator's machine config can never leak
/// into a shared project; `User` implies project-tier may also override, with
/// project winning on collision (most-specific-wins).
/// Test: `tier_ordering_is_by_permissiveness`, `tier_permits_matrix`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CustomizationTier {
    /// No override mechanism touches this section, at any tier.
    Fixed,
    /// A project-tier override may replace it. User tier may not.
    Project,
    /// Both project- and user-tier overrides may replace it.
    User,
}

/// The tier an override arrives from.
///
/// Why: [`CustomizationTier::permits`] needs to distinguish the *declared*
/// permission from the *incoming* attempt; conflating them is how "user config
/// silently changed a shared project" bugs happen.
/// What: the two writable tiers.
/// Test: `tier_permits_matrix`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OverrideTier {
    /// An override authored in the project (`<project>/.trusty-mpm/`).
    Project,
    /// An override authored in the operator's home config (`~/.trusty-mpm/`).
    User,
}

impl CustomizationTier {
    /// Whether an override arriving from `from` may replace this section.
    ///
    /// Why: the single decision point for override permission, so no caller can
    /// re-derive it differently.
    /// What: `Fixed` permits nothing; `Project` permits only
    /// [`OverrideTier::Project`]; `User` permits both.
    /// Test: `tier_permits_matrix`.
    pub const fn permits(self, from: OverrideTier) -> bool {
        match (self, from) {
            (CustomizationTier::Fixed, _) => false,
            (CustomizationTier::Project, OverrideTier::Project) => true,
            (CustomizationTier::Project, OverrideTier::User) => false,
            (CustomizationTier::User, _) => true,
        }
    }
}

/// A named composition-time input the host must supply.
///
/// Why: dynamic content (the deployed-agent roster, the detected stack profile,
/// the project's own addendum) cannot be authored in the package — it is
/// computed per project and session. Naming each one in the schema makes it a
/// declared dependency rather than something a composer may forget to pass.
/// What: the three generators the composer resolves from
/// [`CompositionInputs`].
/// Test: `rejects_package_that_never_consumes_the_roster`, `composes_blocks_in_array_order_with_declared_joins`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Generator {
    /// The rendered delegation roster built from the deployed agents.
    AgentRoster,
    /// The per-project detected stack profile.
    StackProfile,
    /// The project's additive instruction addendum.
    ProjectAddendum,
}

/// The literal bytes inserted before a block when it is not emitted first.
///
/// Why: byte-identical composition is only achievable if every boundary is
/// declared. Blocks split out of one legacy document must rejoin with a plain
/// paragraph break; distinct legacy assets must rejoin with the `---` rule.
/// Inferring that from context would make the output fragile.
/// What: `Rule` = `"\n\n---\n\n"` (matching
/// [`crate::core::instruction_pipeline::SECTION_SEPARATOR`]), `Blank` =
/// `"\n\n"`, `None` = `""`, `Literal` = verbatim.
/// Test: `join_literals_are_exact`, `composes_blocks_in_array_order_with_declared_joins`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Join {
    /// A Markdown horizontal rule: `"\n\n---\n\n"`. The default — it matches
    /// [`crate::core::instruction_pipeline::SECTION_SEPARATOR`], the boundary
    /// today's composer uses between top-level sections.
    #[default]
    Rule,
    /// A paragraph break: `"\n\n"`.
    Blank,
    /// No separator at all.
    None,
    /// Verbatim separator bytes.
    Literal(String),
}

impl Join {
    /// The exact bytes this join contributes.
    ///
    /// Test: `join_literals_are_exact`.
    pub fn as_str(&self) -> &str {
        match self {
            Join::Rule => "\n\n---\n\n",
            Join::Blank => "\n\n",
            Join::None => "",
            Join::Literal(s) => s,
        }
    }
}

/// Where a block's markdown comes from.
///
/// Why: separating authored text from host-supplied generated content lets
/// validation insist that generated content is actually consumed.
/// What: `Text` carries markdown authored in the package; `File` names a
/// markdown source resolved through [`InstructionPackage::sources`] (#9012:
/// runtime content); `Generated` names the generator that supplies it.
///
/// `File` is the schema-v2 addition (#4318). It exists because the two obvious
/// alternatives are both bad: inlining `sections/core.md` verbatim turns 23 KB of
/// reviewable prose into one unreadable JSON line and moves the floor text out of
/// the files `scripts/check_instruction_floor.sh` pins byte-exactly. Since #9012
/// the sources come from the one verified content source the package itself was
/// read from, so a renamed section is a named validation error, never a silent
/// drop.
/// `Text` remains fully expressible, and is how a rule authored *in the manifest*
/// — rather than in a section file — is carried.
///
/// `deny_unknown_fields` is load-bearing, not decoration. Without it a key
/// misplaced *inside* `body` — `{"kind":"text","text":"…","join_before":"blank"}`
/// — was silently discarded, the block fell back to the [`Join::Rule`] default,
/// and a spurious horizontal rule appeared in the composed system prompt while
/// the package parsed, validated and composed without complaint. That is the
/// #4196 failure shape (a package reporting success while composing the wrong
/// instructions), and it also restores parity with the shipped JSON Schema,
/// which sets `additionalProperties: false` on both `body` variants. The tag
/// key itself (`kind`) is consumed by the internal tagging and never reported
/// as unknown.
/// Test: `rejects_unknown_fields_at_every_level_and_names_the_key`, `schema_document_closes_every_object`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
pub enum BlockBody {
    /// Markdown authored inline in the package.
    Text {
        /// The markdown body. Trimmed before emission.
        text: String,
    },
    /// Markdown read from a bundled section source, named by path.
    File {
        /// Path relative to the content bundle's `instructions/`, e.g.
        /// `sections/core.md`. Must be a key of [`InstructionPackage::sources`];
        /// anything else is [`ValidationError::UnknownFileSource`]. Trimmed
        /// before emission.
        path: String,
    },
    /// Markdown supplied at composition time by a named generator.
    Generated {
        /// Which composition-time input supplies this block.
        generator: Generator,
    },
}

impl BlockBody {
    /// The authored markdown this body carries, before trimming.
    ///
    /// Why: `Text` and `File` differ only in where the bytes are stored, and
    /// every caller that cares about authored content — validation, composition,
    /// [`InstructionPackage::authored_run`], and the `CLAUDE.md` override
    /// application — must treat them identically. One accessor is what stops the
    /// v2 addition from having to be remembered at four call sites, which is
    /// exactly how a `File` block would otherwise become silently
    /// non-overridable.
    /// What: `None` for [`BlockBody::Generated`]; `Some(Ok(markdown))` for an
    /// authored body; `Some(Err(path))` for a `File` body naming a source that is
    /// not a key of `sources` (#9012: the package's runtime-content sources).
    /// Test: `file_body_resolves_through_the_bundled_table`,
    /// `unknown_file_source_is_rejected`.
    pub fn authored<'a>(
        &'a self,
        sources: &'a BTreeMap<String, String>,
    ) -> Option<Result<&'a str, &'a str>> {
        match self {
            BlockBody::Text { text } => Some(Ok(text.as_str())),
            BlockBody::File { path } => {
                Some(sources.get(path).map(String::as_str).ok_or(path.as_str()))
            }
            BlockBody::Generated { .. } => None,
        }
    }
}

/// One unit of the ordered composition stream.
///
/// Why: composition order is block order, so the block is the smallest thing
/// that has a position. Attributing each block to a section (rather than
/// nesting blocks under sections) is what lets one section contribute at
/// several non-adjacent positions — the property a faithful, byte-identical
/// lift of today's assets needs.
/// What: the owning section, the body, the join preceding it, and whether it
/// may be dropped when its generator has nothing to supply.
/// Test: `schema_example_deserializes_validates_and_round_trips`, `optional_generated_block_is_dropped_with_its_join`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstructionBlock {
    /// The section this block's content belongs to.
    pub section: SectionId,
    /// Where the content comes from.
    pub body: BlockBody,
    /// Separator emitted before this block (ignored for the first emitted
    /// block). Defaults to [`Join::Rule`].
    #[serde(default, skip_serializing_if = "is_default_join")]
    pub join_before: Join,
    /// Whether this block may be dropped when its generator supplies nothing.
    ///
    /// Only a [`BlockBody::Generated`] block may set this. Everything else that
    /// would render empty is a hard [`CompositionError`], never a silent drop —
    /// the structural guard against #4196.
    #[serde(default, skip_serializing_if = "is_false")]
    pub optional: bool,
    /// Whether this authored block survives a named-section override (#8533).
    ///
    /// Why: agent selection, memory and code search are framework features, not
    /// prose a project tunes. A pinned block carries a feature's statement inside
    /// an otherwise replaceable section, so an override of that section still
    /// emits it. Only an authored block may be pinned; a generated block is
    /// already out of an override's reach.
    #[serde(default, skip_serializing_if = "is_false")]
    pub pinned: bool,
}

/// Serde helper: skip serializing `join_before` when it is the default.
fn is_default_join(join: &Join) -> bool {
    *join == Join::Rule
}

/// Serde helper: skip serializing `optional` when false.
fn is_false(b: &bool) -> bool {
    !*b
}

/// A declared section: identity plus its customization tier.
///
/// Why: the taxonomy and the tier axis are the package's contract with
/// override machinery; keeping them out of the block stream means a section's
/// permission cannot vary by position.
/// What: id, human title, tier, optional prose description.
/// Test: `schema_example_deserializes_validates_and_round_trips`, `rejects_core_declared_overridable`, `rejects_a_second_fixed_section`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstructionSection {
    /// Which section this is.
    pub id: SectionId,
    /// Human-readable title (documentation only; never emitted).
    pub title: String,
    /// Who may override this section.
    pub customization_tier: CustomizationTier,
    /// Optional prose describing the section's remit.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
}

/// A complete PM instruction package.
///
/// Why: one versioned, validatable artifact replaces four `include_str!` blobs
/// whose ordering and override semantics lived only in prose.
/// What: schema version, identity, the eight-section taxonomy, and the ordered
/// block stream. Because the last block is trimmed before emission, the tail is
/// always either `X` or `X\n`; `trailing_newline` selects between those two,
/// which covers both legacy composers (`resolve_pm_prompt` emits none, a
/// file-shaped artifact emits one). Two or more trailing newlines are
/// deliberately inexpressible — if a future target needs them, they belong in a
/// final block's [`Join`], not in this flag.
/// Test: the whole of `instruction_package_tests.rs`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstructionPackage {
    /// Schema major version; must equal [`SCHEMA_VERSION`].
    pub schema_version: u32,
    /// Stable package identity. Never affects composed bytes.
    pub package_id: String,
    /// Optional prose describing the package.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Append exactly one `\n` after the last emitted block.
    #[serde(default, skip_serializing_if = "is_false")]
    pub trailing_newline: bool,
    /// The section taxonomy, in canonical order, each id exactly once.
    pub sections: Vec<InstructionSection>,
    /// The ordered composition stream. Output order is exactly this order.
    pub blocks: Vec<InstructionBlock>,
    /// The markdown a `file` body names, keyed by path relative to the content
    /// bundle's `instructions/` (#9012). Never serialized: the loader fills it
    /// from the same content source the package was read from.
    #[serde(skip)]
    pub sources: BTreeMap<String, String>,
}

/// A structural defect in a package.
///
/// Why: every variant here is a defect that would otherwise surface as a
/// quietly wrong system prompt — the failure mode #4194 and #4196 both had.
/// What: the checks [`InstructionPackage::validate`] performs, in the order it
/// performs them (first failure wins, deterministically).
/// Test: one test per variant in `instruction_package_tests.rs`.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ValidationError {
    /// `schema_version` is not [`SCHEMA_VERSION`].
    #[error("unsupported schema_version {found}; this build implements {SCHEMA_VERSION}")]
    UnsupportedSchemaVersion {
        /// The version the package declared.
        found: u32,
    },
    /// `package_id` is empty or whitespace.
    #[error("package_id must not be empty")]
    EmptyPackageId,
    /// `sections` is not exactly the canonical set in canonical order.
    #[error(
        "sections must list every canonical section exactly once, in canonical order; \
         got {found:?}"
    )]
    SectionsNotCanonical {
        /// The ids as declared.
        found: Vec<SectionId>,
    },
    /// Tier `fixed` was declared for a section other than `core`, or withheld
    /// from `core` (#4286).
    #[error(
        "section {section:?} declares tier {tier:?}; `core` is the only `fixed` \
         section and every other section must be `project`"
    )]
    TierNotCoreOnly {
        /// The offending section.
        section: SectionId,
        /// The tier it declared.
        tier: CustomizationTier,
    },
    /// The package emits nothing.
    #[error("blocks must not be empty")]
    NoBlocks,
    /// An authored (`text` or `file`) block whose content is blank.
    #[error("block {index} ({section:?}) has an empty authored body; remove it instead")]
    EmptyAuthoredBody {
        /// Index into `blocks`.
        index: usize,
        /// The owning section.
        section: SectionId,
    },
    /// An authored (`text` or `file`) block marked optional.
    #[error("block {index} ({section:?}) is an authored block and may not be `optional`")]
    OptionalAuthoredBlock {
        /// Index into `blocks`.
        index: usize,
        /// The owning section.
        section: SectionId,
    },
    /// A `file` block naming a source that is not bundled with this build.
    ///
    /// Why a hard error and not an empty block: a renamed or mistyped section
    /// path is the #4196 shape exactly — content that exists, is referenced, and
    /// never reaches the prompt. The manifest is embedded at compile time, so
    /// this is caught by `bundled_manifest_parses_and_validates` in CI and can
    /// never be reached by a shipped build.
    #[error(
        "block {index} ({section:?}) references section source `{path}`, which this build \
         does not bundle"
    )]
    UnknownFileSource {
        /// Index into `blocks`.
        index: usize,
        /// The owning section.
        section: SectionId,
        /// The unresolvable path as authored.
        path: String,
    },
    /// No block consumes [`Generator::AgentRoster`].
    #[error(
        "no block consumes the agent roster; the computed roster must reach the \
         composed prompt (issue #4196)"
    )]
    RosterNotConsumed,
    /// The roster block is marked optional.
    #[error("block {index} consumes the agent roster and may not be `optional` (issue #4196)")]
    OptionalRoster {
        /// Index into `blocks`.
        index: usize,
    },
    /// A declared section contributes no block.
    #[error("section {section:?} is declared but contributes no block")]
    SectionWithoutBlocks {
        /// The silent section.
        section: SectionId,
    },
    /// A declared section's blocks are all `optional`, so it may emit nothing.
    #[error(
        "section {section:?} contributes only `optional` blocks, so it can emit nothing \
         while still validating; give it at least one non-optional block"
    )]
    SectionOnlyOptionalBlocks {
        /// The section that is not guaranteed to emit.
        section: SectionId,
    },
    // #8533: appended last so no existing variant changes discriminant.
    /// A generated block marked `pinned` (#8533); only authored blocks may be.
    #[error("block {index} ({section:?}) is a generated block and may not be `pinned`")]
    PinnedGeneratedBlock {
        /// Index into `blocks`.
        index: usize,
        /// The owning section.
        section: SectionId,
    },
}

/// A failure while composing a validated package.
///
/// Why: composition failures must be loud. A missing generator input is the
/// exact shape of #4196 — where the roster existed, was computed, and simply
/// never reached the output.
/// What: an invalid package, or a required generator that supplied nothing.
/// Test: `missing_required_generator_input_is_a_hard_error`, `whitespace_only_generator_output_counts_as_absent`.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CompositionError {
    /// The package failed [`InstructionPackage::validate`].
    #[error("invalid instruction package: {0}")]
    Invalid(#[from] ValidationError),
    /// A non-optional generated block had no content to emit.
    #[error(
        "block {index} requires generator {generator:?} but it supplied no content; \
         mark the block `optional` if that is intended"
    )]
    MissingGeneratedInput {
        /// Index into `blocks`.
        index: usize,
        /// The generator that came up empty.
        generator: Generator,
    },
}

/// The host-supplied, composition-time inputs.
///
/// Why: making the roster a required, non-`Option` field of the composer's
/// input means a caller cannot compose without producing one — the final-step
/// drop that caused #4196 is not expressible.
/// What: the rendered roster (required) plus the two optional project inputs.
/// Test: `composes_blocks_in_array_order_with_declared_joins`, `missing_required_generator_input_is_a_hard_error`.
#[derive(Debug, Clone, Default)]
pub struct CompositionInputs {
    /// Rendered delegation roster. Required — see #4196.
    pub agent_roster: String,
    /// Rendered per-project stack profile, if the host derived one.
    pub stack_profile: Option<String>,
    /// The project's additive instruction addendum, if present.
    pub project_addendum: Option<String>,
}

impl InstructionPackage {
    /// Parse a package from JSON.
    ///
    /// Why: unknown fields are rejected (`deny_unknown_fields` throughout) so a
    /// typo in a key becomes a parse error rather than silently dropped
    /// instruction content.
    ///
    /// The `schema_version` probe runs FIRST, before the package is
    /// deserialized, because strictness and version-gating otherwise collide:
    /// a package written against a later schema almost certainly carries a field
    /// this build does not know, so plain deserialization would report `unknown
    /// field \`whatever\`` — blaming a key that is perfectly valid in its own
    /// schema — instead of the actionable "you need a newer trusty-mpm". The
    /// probe deliberately ignores unknown fields; it reads nothing but the
    /// version.
    ///
    /// What: version probe, then `serde_json::from_str`. No structural
    /// validation — call [`Self::validate`] or [`Self::compose`] for that.
    /// Test: `schema_example_deserializes_validates_and_round_trips`,
    /// `rejects_unknown_fields_at_every_level_and_names_the_key`,
    /// `later_schema_version_is_reported_as_a_version_error_not_a_field_error`.
    pub fn from_json(raw: &str) -> Result<Self, serde_json::Error> {
        /// Reads only `schema_version`, tolerating every other key by design.
        #[derive(Deserialize)]
        struct VersionProbe {
            schema_version: u32,
        }

        let probe: VersionProbe = serde_json::from_str(raw)?;
        if probe.schema_version != SCHEMA_VERSION {
            return Err(serde::de::Error::custom(format!(
                "unsupported schema_version {}; this build implements {SCHEMA_VERSION}",
                probe.schema_version
            )));
        }
        serde_json::from_str(raw)
    }

    /// Serialize back to pretty JSON.
    ///
    /// Why: round-tripping must be lossless so tooling can rewrite a package
    /// without churning unrelated bytes.
    /// What: `serde_json::to_string_pretty`; field order is struct declaration
    /// order, and both arrays keep their order.
    /// Test: `schema_example_deserializes_validates_and_round_trips`.
    pub fn to_json(&self) -> Result<String, serde_json::Error> {
        serde_json::to_string_pretty(self)
    }

    /// Look up a declared section.
    ///
    /// Test: `section_lookup_reports_declared_tier`.
    pub fn section(&self, id: SectionId) -> Option<&InstructionSection> {
        self.sections.iter().find(|s| s.id == id)
    }

    /// Check every structural invariant, first failure wins.
    ///
    /// Why: composition assumes these hold; checking them in one deterministic
    /// order gives authors a stable error and reviewers one place to read the
    /// contract.
    /// What: version, identity, canonical section set/order, floor tiers,
    /// non-empty block stream, per-block text/optional sanity, every section
    /// contributing *and being guaranteed to emit* (owning blocks is not
    /// enough — a section covered only by `optional` blocks composes to
    /// nothing), the roster being consumed and non-optional, and nothing
    /// overridable following the floor.
    /// Test: one test per [`ValidationError`] variant.
    pub fn validate(&self) -> Result<(), ValidationError> {
        if self.schema_version != SCHEMA_VERSION {
            return Err(ValidationError::UnsupportedSchemaVersion {
                found: self.schema_version,
            });
        }
        if self.package_id.trim().is_empty() {
            return Err(ValidationError::EmptyPackageId);
        }

        let declared: Vec<SectionId> = self.sections.iter().map(|s| s.id).collect();
        if declared != SectionId::CANONICAL {
            return Err(ValidationError::SectionsNotCanonical { found: declared });
        }

        // #4286: `core` is protected, everything else is a project's to replace.
        // Stated as an iff so BOTH failure directions are caught — retiering
        // `core` to `project` (losing the one protection) and retiering any
        // other section to `fixed` (quietly reinstating a floor).
        for section in &self.sections {
            // #8533: the fixed set is the safety core's, enumerated once.
            let should_be_fixed =
                crate::core::instruction_safety_core::is_fixed_core_section(section.id);
            let is_fixed = section.customization_tier == CustomizationTier::Fixed;
            if should_be_fixed != is_fixed {
                return Err(ValidationError::TierNotCoreOnly {
                    section: section.id,
                    tier: section.customization_tier,
                });
            }
        }

        if self.blocks.is_empty() {
            return Err(ValidationError::NoBlocks);
        }

        for (index, block) in self.blocks.iter().enumerate() {
            let Some(authored) = block.body.authored(&self.sources) else {
                // #8533: pinning is meaningful only for authored text.
                if block.pinned {
                    return Err(ValidationError::PinnedGeneratedBlock {
                        index,
                        section: block.section,
                    });
                }
                continue;
            };
            let body = match authored {
                Ok(body) => body,
                Err(path) => {
                    return Err(ValidationError::UnknownFileSource {
                        index,
                        section: block.section,
                        path: path.to_string(),
                    });
                }
            };
            if body.trim().is_empty() {
                return Err(ValidationError::EmptyAuthoredBody {
                    index,
                    section: block.section,
                });
            }
            if block.optional {
                return Err(ValidationError::OptionalAuthoredBlock {
                    index,
                    section: block.section,
                });
            }
        }

        // Roster checks run BEFORE section coverage so that an optional roster
        // block reports the precise `OptionalRoster` (#4196) diagnosis rather
        // than the broader "this section may emit nothing" one — both are true,
        // but only the first names the actual mistake.
        self.validate_roster()?;

        // A section must not merely OWN blocks — it must own at least one block
        // that is guaranteed to emit. A section covered only by `optional`
        // blocks passes the ownership check and still composes to nothing,
        // which is precisely the silent-missing-section shape this check exists
        // to prevent (#4069/#4196: the taxonomy claims the content is
        // delivered, the output does not contain it).
        let covered: BTreeSet<SectionId> = self.blocks.iter().map(|b| b.section).collect();
        let guaranteed: BTreeSet<SectionId> = self
            .blocks
            .iter()
            .filter(|b| !b.optional)
            .map(|b| b.section)
            .collect();
        for id in SectionId::CANONICAL {
            if !covered.contains(&id) {
                return Err(ValidationError::SectionWithoutBlocks { section: id });
            }
            if !guaranteed.contains(&id) {
                return Err(ValidationError::SectionOnlyOptionalBlocks { section: id });
            }
        }

        Ok(())
    }

    /// The roster must reach the output, and must not be droppable (#4196).
    ///
    /// What today's launch path actually does, since #4186 lifts exactly these
    /// bytes: `instruction_overrides::resolve_pm_prompt` resolves its delegation
    /// section through `bundled_delegation`, which since #4196 (closing #4069)
    /// appends the LIVE computed roster from
    /// `delegation_authority::deployed_roster_section` to the bundled
    /// `AGENT_DELEGATION` asset, separated by `ROSTER_PRECEDENCE_NOTE`. On this
    /// path the delivered prompt therefore already carries the real roster; the
    /// stale `PipelineOutput::merged` path that originally caused #4069 is no
    /// longer the launch path.
    ///
    /// Consequence for #4186, stated because an earlier revision of this comment
    /// got it wrong: byte-identity and roster consumption are **not** in
    /// tension. Today's section is
    /// `<asset>\n\n<ROSTER_PRECEDENCE_NOTE>\n\n<roster>`, which this schema
    /// expresses directly as three blocks — [`BlockBody::Text`] for the asset, a
    /// [`Join::Blank`] [`BlockBody::Text`] for the note, and a [`Join::Blank`]
    /// [`BlockBody::Generated`] for [`Generator::AgentRoster`] — so a package can
    /// be byte-identical to today's output AND consume the computed roster.
    /// #4186 should therefore keep **strict byte-equality against today's
    /// output** as its acceptance gate; it is the strongest faithfulness check
    /// available and nothing here forces weakening it.
    ///
    /// Scope — including the other override configurations `resolve_pm_prompt`
    /// can emit, and which of them this schema can reproduce — is tracked on
    /// #4186, not here.
    /// Test: `todays_delegation_section_shape_is_expressible`.
    fn validate_roster(&self) -> Result<(), ValidationError> {
        let mut seen = false;
        for (index, block) in self.blocks.iter().enumerate() {
            if matches!(
                block.body,
                BlockBody::Generated {
                    generator: Generator::AgentRoster
                }
            ) {
                if block.optional {
                    return Err(ValidationError::OptionalRoster { index });
                }
                seen = true;
            }
        }
        if seen {
            Ok(())
        } else {
            Err(ValidationError::RosterNotConsumed)
        }
    }

    // `validate_floor_is_last` was deleted by #4286 along with the floor it
    // enforced. Block order is declared by the manifest's `blocks` array and is
    // no longer constrained by which section a block belongs to.

    /// Compose the package into the final prompt text.
    ///
    /// Why: this is the executable definition of the format's ordering and
    /// whitespace semantics — the thing #4186 diffs against today's
    /// `resolve_pm_prompt` output.
    ///
    /// What: validates, then walks `blocks` in array order. Each block's body is
    /// resolved (authored text, or the named generator's input) and trimmed; a
    /// block that resolves to nothing is dropped when `optional`, and is a hard
    /// [`CompositionError::MissingGeneratedInput`] otherwise. Each body is then
    /// folded ON ITS OWN by [`crate::core::instruction_fold::fold_block`], which
    /// closes a fence the body leaves open, and a body the fold empties emits
    /// nothing. Every emitted block after the first is
    /// preceded by its declared [`Join`] bytes. `trailing_newline` appends one
    /// `\n`.
    ///
    /// Folding per block is a safety property, not a style choice (#8533): a
    /// project override body ending in an unclosed `<!--` hides only its own
    /// tail, and one ending in an open fence is closed where it ends. Folded as one string, it hid every later block up to the next
    /// `-->`, safety core included.
    ///
    /// Determinism: pure function of `(self, inputs)` — no map iteration, no
    /// clock, no environment, no filesystem. Two calls with equal arguments
    /// return byte-identical strings.
    ///
    /// Test: `composes_blocks_in_array_order_with_declared_joins`,
    /// `first_emitted_block_never_contributes_a_join`,
    /// `compose_is_byte_identical_across_repeats_and_a_json_round_trip`.
    pub fn compose(&self, inputs: &CompositionInputs) -> Result<String, CompositionError> {
        self.validate()?;

        let mut out = String::new();
        let mut emitted = false;

        for (index, block) in self.blocks.iter().enumerate() {
            let resolved: Option<&str> = match &block.body {
                BlockBody::Text { .. } | BlockBody::File { .. } => {
                    match block.body.authored(&self.sources) {
                        Some(Ok(body)) => Some(body),
                        // `validate` above rejects an unresolvable `file` path.
                        _ => unreachable!("validate rejects unknown file sources"),
                    }
                }
                BlockBody::Generated { generator } => match generator {
                    Generator::AgentRoster => Some(inputs.agent_roster.as_str()),
                    Generator::StackProfile => inputs.stack_profile.as_deref(),
                    Generator::ProjectAddendum => inputs.project_addendum.as_deref(),
                },
            };

            let body = resolved.map(str::trim).unwrap_or("");
            if body.is_empty() {
                if block.optional {
                    continue;
                }
                let BlockBody::Generated { generator } = &block.body else {
                    // Blank authored blocks are rejected by `validate`.
                    unreachable!("validate rejects blank authored blocks");
                };
                return Err(CompositionError::MissingGeneratedInput {
                    index,
                    generator: *generator,
                });
            }

            // #7616: the one transformation between the authored corpus and the
            // delivered bytes. #8533: applied per block, so no block's comment
            // or fence state can reach the next one: `fold_block` closes a
            // fence the block leaves open.
            let folded = crate::core::instruction_fold::fold_block(body);
            if folded.is_empty() {
                continue;
            }
            if emitted {
                out.push_str(block.join_before.as_str());
            }
            out.push_str(&folded);
            emitted = true;
        }

        if self.trailing_newline && !out.is_empty() {
            out.push('\n');
        }
        Ok(out)
    }

    /// Concatenate the AUTHORED blocks of `sections`, in block order.
    ///
    /// Why: the legacy assembly in
    /// [`crate::core::instruction_overrides::assemble_sections`] and the
    /// roster-free [`crate::core::instruction_pipeline::assemble_system_prompt`]
    /// both need whole multi-section runs as one string, and neither can call
    /// [`Self::compose`] — one composes a different configuration, the other has
    /// no agent roster to satisfy the required `agent-roster` generator. Before
    /// #4318 they rebuilt those runs from the `include_str!` constants directly,
    /// which was fine only while every byte of prose lived in a section file. Now
    /// that the manifest may author a rule inline, rebuilding from the constants
    /// would deliver that rule to package-composed sessions and silently withhold
    /// it from every other path — a content split-brain. Projecting the manifest
    /// is what keeps ONE source of truth.
    ///
    /// What: walks `blocks` in array order, keeps blocks owned by `sections` whose
    /// body is authored ([`BlockBody::Text`] or [`BlockBody::File`]), trims each,
    /// and joins with each block's declared [`Join`] — skipping the join on the
    /// first emitted block, exactly as [`Self::compose`] does. `generated` blocks
    /// are skipped: they have no authored bytes, and their host inputs are folded
    /// in by the callers separately. An unresolvable `file` path is skipped rather
    /// than panicking, because [`Self::validate`] already rejects it at the one
    /// place that matters.
    ///
    /// Determinism: same guarantees as [`Self::compose`] — array order, declared
    /// joins, no map iteration, no I/O.
    ///
    /// Test: `authored_run_projects_blocks_in_order_with_joins`,
    /// `authored_run_skips_generated_blocks`,
    /// `pm_instructions_is_the_pm_body_sections`.
    pub fn authored_run(&self, sections: &[SectionId]) -> String {
        let mut out = String::new();
        let mut emitted = false;

        for block in &self.blocks {
            if !sections.contains(&block.section) {
                continue;
            }
            let Some(Ok(body)) = block.body.authored(&self.sources) else {
                continue;
            };
            let body = body.trim();
            if body.is_empty() {
                continue;
            }
            if emitted {
                out.push_str(block.join_before.as_str());
            }
            out.push_str(body);
            emitted = true;
        }

        out
    }
}

#[cfg(test)]
#[path = "instruction_package_tests.rs"]
mod tests;