systemg 0.66.6

An agent-friendly general-purpose program orchestrator for busy people.
Documentation
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
---
title: Configuration
---

# Configuration

systemg uses YAML files to define services and their relationships.

## Complete example

```yaml
version: "2"
projects:
  myapp:
    name: My App
    services:
      # ... service definitions (see below)
```

A single file can declare many projects under the `projects:` map, keyed by
project id. The example below shows one project's full set of sections. Project
entries may override `env` and `logs`; top-level `metrics` and `status` settings
apply across the loaded projects.

```yaml
version: "2"
project:
  id: myapp
  name: My App
env:
  vars:
    APP_ENV: "production"
logs:
  sink: file
  max_bytes: 10485760
  max_files: 5
status:
  snapshot_mode: summary
  snapshot_interval_secs: 5
services:
  postgres:
    command: "postgres -D /var/lib/postgresql/data"
    restart_policy: "always"
  redis:
    command: "redis-server /etc/redis/redis.conf"
    restart_policy: "always"
  api:
    command: >
      gunicorn app:application
      --bind 0.0.0.0:8000
    env:
      file: "/etc/myapp/production.env"
      vars:
        PORT: "8000"
        DATABASE_URL: "postgres://localhost/myapp"
    depends_on:
      - postgres
      - redis
    restart_policy: "always"
    backoff: "10s"
    deployment:
      pre_start: "python manage.py migrate"
      health_check:
        command: "curl --fail http://localhost:8000/health"
    hooks:
      onstart:
        command: "echo 'API started'"
      onerr:
        command: "curl --request POST https://alerts.example.com/api/crash"
  worker:
    command: >
      celery -A tasks worker
      --loglevel=info
    depends_on:
      - redis
    restart_policy: "on-failure"
    max_restarts: 5
  backup:
    command: >
      pg_dump mydb >
      /backups/db-$(date +%Y%m%d).sql
    cron:
      expression: "0 0 2 * * *"
```

## Supervisor configuration

Project manifests describe workloads. Supervisor-wide defaults live separately
in `~/.local/share/systemg/supervisor.xml`, or
`/var/lib/systemg/supervisor.xml` with `--sys`:

```xml
<supervisor>
  <logs>
    <max_bytes>10485760</max_bytes>
    <max_files>5</max_files>
  </logs>
  <timeouts>
    <pre_start_secs>300</pre_start_secs>
    <startup_stability_ms>250</startup_stability_ms>
    <stop_verify_secs>10</stop_verify_secs>
    <start_settle_secs>360</start_settle_secs>
    <command_wait_secs>900</command_wait_secs>
  </timeouts>
  <start>
    <max_concurrent>-1</max_concurrent>
  </start>
</supervisor>
```

- `pre_start_secs`: default execution budget for deployment `pre_start` commands.
- `startup_stability_ms`: survival window for services without a health check.
- `stop_verify_secs`: time allowed to confirm that a terminated process is gone.
- `start_settle_secs`: maximum wait for an unresolved queued project start.
- `command_wait_secs`: how long a CLI command waits for the supervisor's reply
  before reporting [SG0111](/reference/dialog/codes#sg0111). `0` waits
  indefinitely. Raise it when a project is large enough that a restart runs
  longer than the default; the command is never cancelled by the wait ending.
- `max_concurrent`: how many services a bulk start runs at once. `-1` (the
  default) starts every service whose dependencies have resolved, `1` starts
  one service at a time, and any other positive number is a cap. Applies to
  `sysg start` and to project boot; `restart` remains one service at a time.

The file is created on first supervisor start. Existing compact XML remains
compatible and is rewritten in the indented form after it parses successfully.
`SYSG_PRE_START_TIMEOUT_SECS` remains a higher-precedence compatibility override.

<Info>
  Health-check `attempt_timeout` and `total_timeout` remain in the project
  manifest because readiness is workload-specific. The IPC poll slice, liveness
  probe window, unresponsive grace and live-upgrade deadlines are protocol
  invariants and are not operator settings.
</Info>

## Registered manifests

When you run a command with `-c <file>`, the resident supervisor remembers that
manifest's resolved path for each project it registers. Later commands can omit
`-c` and target the registered project from any working directory.

<Info>
  Ad-hoc units use the same manifest pipeline. `sysg start -- <command...>`
  first stages the command as a generated version 2 manifest. If a supervisor
  is already running, that file is not registered or started until you run the
  explicit `sysg start --config ...` command systemg prints. See
  [Units](/docs/examples/units).
</Info>

`sysg restart` treats the files at those paths as the source of truth. It reads
and validates every registered manifest before touching a process, then
re-registers added, changed, and removed projects and services. An invalid
manifest returns [`SG0301`](/reference/dialog/codes#sg0301) and leaves the
running workloads unchanged.

```sh
# submit the manifest once
$ sysg start -c stack.yaml --daemonize

# later, -c is optional even after editing stack.yaml
$ sysg status

# adopt the current manifest and reconcile its changes
$ sysg restart
```

## Configuration sections

### `version`

**Required**. Specifies the configuration schema version. The current schema is
`2`. Older `version: "1"` manifests are no longer accepted. For a legacy
singular-`project:` manifest, run
[`sysg migrate`](/reference/commands/migrate), which converts the shape and
emits `version: "2"`. For a loose or existing `projects:` manifest, update the
version field directly. When upgrading from 0.54.x or older, also follow the
[state-layout migration](/docs/how-it-works/state#persistence).

```yaml
version: "2"
```

### `projects`

The canonical way to declare projects. A map keyed by project id, where each
entry carries that project's `name`, `services`, and optional `env` / `logs`
sections. One file can hold as many projects as you like:

```yaml
version: "2"
projects:
  arbitration:
    name: Arbitration
    services:
      worker:
        command: "python worker.py"
  gamecast:
    services:
      api:
        command: "python api.py"
```

Each key is the `project.id`. When a `name` is omitted, the id doubles as the
display name. Every project gets its own state directory — see
[State](/docs/how-it-works/state) — and you target each one by id with `-p/--project`
at runtime.

<Warning>
  Treat the project id (the map key) as durable runtime identity. Changing it
  does not rename a project — it creates a **new** namespace, and the old one's
  running services become orphaned state. Rename freely via `name`; never rename
  by editing the id.
</Warning>

#### Loose (project-less) services

Top-level `services:` with no project form a **loose bundle**. They still run,
and their state persists under `projects/__loose__/`:

```yaml
version: "2"
services:
  web:
    command: "python app.py"
```

Use this for single-service or quick setups where a project id adds no value.

#### Singular `project:` (deprecated)

The older singular block still parses, so existing single-project manifests keep
working — but it emits a deprecation warning. Prefer `projects:`.

```yaml
project:
  id: arbitration
  name: Arbitration
```

The shorthand `project: arbitration` (which sets both `id` and `name`) is also
still accepted. Convert old-shape manifests with
[`sysg migrate`](/reference/commands/migrate).

See [Projects](/docs/how-it-works/projects) for how one supervisor hosts many
projects at once and how `-p/--project` targets them at runtime.

### `env`

Optional environment variables shared by all services.

```yaml
env:
  vars:
    LOG_LEVEL: "info"
    APP_ENV: "production"
  file: "/etc/myapp/common.env"
```

### `logs`

Optional defaults for service stdout/stderr handling.

```yaml
logs:
  sink: file
  max_bytes: 10485760
  max_files: 5
```

Fields:

- `sink`: `file` captures service output to systemg-managed log files. `none` discards service output without creating log-writer threads or files.
- `max_bytes`: active log-file size before rotation for the `file` sink.
- `max_files`: number of rotated files to retain per active log.

Use `sink: none` for noisy production services when service output is already collected by another logging pipeline.

### `status`

Optional defaults for `status` and `inspect` runtime detail.

```yaml
status:
  snapshot_mode: summary
  snapshot_interval_secs: 5
```

Fields:

- `snapshot_mode`: `off`, `summary`, or `detailed`.
- `snapshot_interval_secs`: seconds between background snapshot refreshes, clamped between 1 and 300.

Modes:

- `summary`: default. Tracks service state, pid, health, last exit, cron state, and sampled metric summaries while skipping expensive process tree expansion.
- `detailed`: includes runtime command details and process/spawn descendants for richer `inspect` output.
- `off`: disables background runtime snapshot refresh and uses persisted state plus pid files.

For large deployments, keep `summary` globally and use focused `inspect --service` workflows when deeper investigation is needed.

### `metrics`

Optional tuning for the CPU/memory sampling that powers `status` and `inspect`.

```yaml
metrics:
  retention_minutes: 720
  sample_interval_secs: 1
  max_memory_bytes: 10485760
  spillover_path: ".state/metrics"
```

Fields:

- `retention_minutes`: minutes of in-memory samples to keep (default 720).
- `sample_interval_secs`: seconds between samples, clamped 1-60 (default 1).
- `max_memory_bytes`: memory cap across all sample buffers (default 10 MiB).
- `spillover_path`: optional directory for spilling older samples to disk, with
  `spillover_max_bytes` and `spillover_segment_bytes` controlling disk usage.

### `services`

Defines the services to manage. Each entry under `projects:` requires its own
`services:` map. A top-level `services:` map is optional and defines the loose
bundle.

```yaml
services:
  web:
    command: "python app.py"
```

### `!include`

Splits a manifest across files: an `!include <path>` tag at any node is
replaced by the parsed content of the referenced file. Relative paths resolve
against the directory of the file doing the including, and included files can
include further files.

```yaml
version: "2"
projects:
  api: !include projects/api.yaml
  worker:
    services: !include services/worker.yaml
```

```yaml
# projects/api.yaml — a fragment is a plain replacement value;
# only the root manifest declares `version:`
services:
  server:
    command: "python app.py"
```

<Info>
`!include` uses YAML's standard *local tag* syntax, part of both the
[YAML 1.1](https://yaml.org/spec/1.1/#id858600) and
[YAML 1.2](https://yaml.org/spec/1.2.2/#24-tags) specs, so an include-bearing
manifest is valid YAML to any spec-conformant parser. The inclusion behavior
itself is sysg-specific: the spec deliberately leaves
[local tag semantics](https://yaml.org/spec/1.2.2/#691-node-tags) to the
application, so generic tools (`yq`, linters) parse the tag but do not resolve
it, and strict loaders that reject unknown tags (such as PyYAML's `safe_load`)
refuse to construct it.
</Info>

Fragments are held to the same trust bar as the root manifest, `${VAR}`
expansion applies to included content, and every command (`validate`,
`status`, `restart`, `upgrade`) sees the assembled result. A missing or broken
fragment is always a hard error carrying the include chain
([SG0207](/reference/dialog/codes#sg0207)) — never a partially loaded
manifest — and cyclic includes ([SG0208](/reference/dialog/codes#sg0208))
or includes past the depth/size caps
([SG0209](/reference/dialog/codes#sg0209)) are refused. Include paths
themselves cannot use `${VAR}` expansion. `sysg migrate` preserves `!include`
tags unresolved.

## Service configuration

### `command`

The command to execute, run through `sh -c`. Required unless the service
declares [`exec`](#exec) instead.

```yaml
services:
  web:
    command: "python app.py"
```

The shell stays alive for as long as the service does, and it is the process
systemg tracks: signals, CPU and memory readings, and the recorded exit status
all describe the shell rather than the program inside it. On most Linux systems
`/bin/sh` is `dash`, which does not replace itself with the command, so a shell
form service costs one extra process and one extra entry in `status`.

Use it when the service genuinely needs a shell - a pipeline, `&&`, a glob, a
variable expansion. Reach for `exec` when it does not.

### `exec`

The program and its arguments as a list. systemg runs it directly, with no
shell in between, so the tracked process is the workload itself.

```yaml
services:
  web:
    exec: ["python", "app.py"]
```

A service declares `command` or `exec`, never both. Nothing is quoted, split,
or expanded: each list entry is passed through as one argument, so a value
containing spaces stays one argument.

### `working_dir`

The directory the service runs in, relative to the manifest's directory or
absolute. Without it, a service that needs another directory has to say
`cd elsewhere && ...`, which forces the shell form.

```yaml
services:
  web:
    working_dir: "services/api"
    exec: ["python", "app.py"]
```

### `depends_on`

Services that must start before this one.

```yaml
services:
  api:
    command: "python app.py"
    depends_on:
      - postgres
      - redis
```

`depends_on` is the only thing that orders a start. Services with no dependency
between them start at the same time, and a service waits for the dependencies it
declared and for nothing else — an unrelated slow service never holds it back.
Position in the manifest has never ordered anything and does not now.

<Warning>
  If a service needs another one up first, declare it. A service that relied on
  a manifest's ordering without saying so will now start alongside what it used
  to follow. Set `max_concurrent` to `1` in
  [`supervisor.xml`](#supervisor-configuration) to restore one-at-a-time startup
  while you add the missing `depends_on` entries.
</Warning>

### `env`

Service-specific environment configuration.

```yaml
services:
  api:
    command: "python app.py"
    env:
      vars:
        PORT: "8000"
        DATABASE_URL: "postgres://localhost/myapp"
      file: "/etc/myapp/production.env"
```

### `restart_policy`

Control how services recover from crashes.

```yaml
services:
  api:
    command: "python app.py"
    restart_policy: "always"
    backoff: "5s"
    max_restarts: 10
```

### Service `logs`

Override global logging settings for one service.

```yaml
services:
  api:
    command: "python app.py"
    logs:
      sink: file
      max_bytes: 5242880
      max_files: 3
  noisy_worker:
    command: "worker --verbose"
    logs:
      sink: none
```

**Policies:**
- `always` - Restart on non-zero exit codes
- `on-failure` - Restart on non-zero exit codes
- `never` - Don't restart

A clean (zero) exit is treated as intentional and never triggers a restart,
regardless of policy. Restarts respect `backoff` between attempts and stop
after `max_restarts` (unlimited when unset).

### `hooks`

Run commands after successful starts or unsuccessful exits.

```yaml
services:
  api:
    command: "python app.py"
    hooks:
      onstart:
        command: "curl --request POST https://status.example.com/api/up"
      onerr:
        command: "/usr/local/bin/report-crash api"
```

### `cron`

Run services on a schedule instead of continuously.

```yaml
services:
  backup:
    command: >
      pg_dump mydb >
      /backups/db-$(date +%Y%m%d).sql
    cron:
      expression: "0 0 2 * * *"
```

### `deployment`

Control how services update during restarts.

```yaml
services:
  api:
    command: "python app.py"
    deployment:
      strategy: "rolling"
      pre_start: "python manage.py migrate"
      health_check:
        command: "curl --fail http://localhost:8000/health"
        interval: "5s"
        attempt_timeout: "30s"
        total_timeout: "5m"
        retries: 3
      grace_period: "5s"
      blue_green:
        env_var: "PORT"
        slots: ["8000", "8001"]
        candidate_health_check:
          command: "curl --fail http://127.0.0.1:{slot}/health"
          interval: "2s"
        switch_command: "/usr/local/bin/switch-upstream {candidate_slot}"
        switch_verify:
          command: "curl --fail http://localhost:8000/health"
        state_path: ".state/api-slot.xml"
```

Rolling deployments start the new instance, wait for health checks, then stop the old instance. For single-host zero-downtime with fixed ports, use `blue_green` so traffic can be switched between two slots. A [blue-green deployment](https://en.wikipedia.org/wiki/Blue-green_deployment) uses two identical slots, starts the new version in the idle slot, verifies it, and then switches traffic only after the candidate is ready.

## Field reference

### Service fields

Primary keys available on each service definition.

| Field | Type | Description |
|-------|------|-------------|
| `command` | string | Command to execute through `sh -c` (required unless `exec` is set) |
| `exec` | array | Program and arguments run directly, with no shell |
| `working_dir` | string | Directory the service runs in |
| `depends_on` | array | Services that must start first |
| `env` | object | Environment configuration |
| `restart_policy` | string | `always`, `on-failure`, or `never` |
| `backoff` | string | Time between restart attempts |
| `max_restarts` | number | Maximum restart attempts |
| `hooks` | object | Lifecycle event handlers |
| `cron` | object | Cron schedule (`expression`, optional `timezone`) |
| `deployment` | object | Update strategy configuration |
| `logs` | object | Service stdout/stderr capture and rotation settings |
| `skip` | bool or string | Skip this service, or a command whose success skips it |
| `spawn` | object | Dynamic child-process policy (`mode`, `limits`) |
| `user` / `group` | string | Run the service as this user/group (privileged mode) |
| `supplementary_groups` | array | Extra groups applied before dropping privileges |
| `capabilities` | array | Linux capabilities retained after the privilege drop |
| `limits` | object | Resource limits (`nofile`, `nproc`, `memlock`, `nice`, `cpu_affinity`, `cgroup`) |
| `isolation` | object | Namespace isolation (`network`, `mount`, `pid`, `user`) |

`user`, `group`, `supplementary_groups`, `capabilities`, `limits`, and
`isolation` only take effect in privileged mode - see
[System mode](/docs/kernel-mode/system-mode) for details and examples.

Health checks are configured under `deployment.health_check`, not as a
top-level service key.

### Environment object

Environment sources and inline overrides merged into the service process environment.

| Field | Type | Description |
|-------|------|-------------|
| `vars` | object | Key-value environment variables |
| `file` | string | Path to env file |
| `inherit_env` | bool | Let a privilege-dropped service inherit the supervisor's environment instead of starting clean (default `false`) |
| `clear_session_vars` | bool | Strip session-scoped variables like `SSH_*` and `DISPLAY` (default `true`) |
| `strip` | array | Additional variable names to remove from the service environment |

### Hooks object

Commands triggered by service outcomes.

| Field | Type | Description |
|-------|------|-------------|
| `onstart` | object | Command run after readiness or successful one-shot completion |
| `onerr` | object | Command run after an unsuccessful service exit |

Each hook supports:
- `command` - Command to execute
- `timeout` - Maximum execution time

### Health check object

Probe configuration used to determine readiness/health during deployment workflows.

| Field | Type | Description |
|-------|------|-------------|
| `command` | string | Check command |
| `url` | string | HTTP endpoint (alternative to command) |
| `interval` | string | Time between attempts (default `2s`); must be greater than zero |
| `attempt_timeout` | string | Maximum time for a **single** probe (default `30s`) |
| `total_timeout` | string | Minimum total readiness window before giving up; `timeout` is accepted as a compatibility alias |
| `retries` | number | Minimum attempts before giving up (default `3`) |

<Note>
`attempt_timeout` bounds **one** probe. `total_timeout` controls the whole
readiness window, so connection refusals that return immediately do not exhaust
a slow-starting service's budget. A check fails only after both `retries` and
`total_timeout` are exhausted. The failure carries a code by cause:
[`SG0022`](/reference/dialog/codes#sg0022) (could not reach),
[`SG0023`](/reference/dialog/codes#sg0023) (a probe timed out), or
[`SG0104`](/reference/dialog/codes#sg0104) (ran but reported unhealthy).
</Note>

### Durations

Every duration-valued field — `backoff`, `grace_period`, hook `timeout`, and the
health-check windows — is a whole number with an optional unit:

| Unit | Meaning | Example |
|------|---------|---------|
| `ms` | milliseconds | `interval: "100ms"` |
| `s` | seconds | `backoff: "10s"` |
| `m` | minutes | `total_timeout: "5m"` |
| `h` | hours | `total_timeout: "1h"` |

A bare number is seconds, so `15` and `15s` are the same value. Fractions
(`0.5s`) are not accepted — write them in a smaller unit (`500ms`).

Durations are checked when the manifest is loaded, so `sysg validate` refuses
exactly what `sysg start` refuses, naming the offending field's path
([`SG0210`](/reference/dialog/codes#sg0210)).

### Deployment object

Controls how restarts are performed and what validation happens before cutover.

| Field | Type | Description |
|-------|------|-------------|
| `strategy` | string | `rolling` or `immediate` |
| `pre_start` | string | Command that must exit successfully before starting; its process tree is terminated after the configured supervisor command budget ([`SG0108`](/reference/dialog/codes#sg0108)) |
| `health_check` | object | Health check configuration |
| `grace_period` | string | Time before stopping old instance |
| `blue_green` | object | Single-host blue/green rollout settings |

<Note>
  `pre_start` runs from the manifest directory with the service environment.
  Its output is captured in the service log. A non-zero exit is
  [`SG0103`](/reference/dialog/codes#sg0103); exceeding the `supervisor.xml`
  `pre_start_secs` budget is [`SG0108`](/reference/dialog/codes#sg0108), and
  the service is not launched.
</Note>

### Blue/green deployment object

Single-host zero-downtime options for alternating between two rollout slots (typically ports).

| Field | Type | Description |
|-------|------|-------------|
| `env_var` | string | Env var injected with slot value (`PORT` default) |
| `slots` | array | Exactly two slot values to alternate between |
| `switch_command` | string | Command to switch traffic to candidate slot |
| `candidate_health_check` | object | Optional candidate verification check (`{slot}` supported in `url` or `command`) |
| `switch_verify` | object | Optional post-switch verification check |
| `state_path` | string | Optional persisted active-slot state file path |

::::info Manifest schema compatibility

The top-level `version` field declares the manifest schema version. The current schema is `2`, accepted as either a string or integer, so `version: "2"` and `version: 2` are equivalent. `version: "1"` is no longer accepted.

systemg reads the declared version before the rest of the manifest. Version `1`
is rejected rather than silently reinterpreted; version `2` is the only current
runtime schema. systemg never rewrites a manifest as a side effect of starting
services.

When downgrading, the older binary can only parse versions it knows. Keep the
previous manifest or convert it before starting an older release.

Schema validation is separate from manifest *shape* conversion. To rewrite an
old singular-`project:` manifest into the canonical
`projects:` map, run [`sysg migrate`](/reference/commands/migrate) — it prints
the converted YAML to stdout unless `--in-place` is requested.

::::