@topy-ai/maggie 0.7.48 → 0.7.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +31 -4
  3. package/bin/maggie.js +17 -4
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
  5. package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
  6. package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
  7. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
  8. package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
  9. package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
  10. package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
  11. package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
  12. package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
  13. package/bundled-references/google-integrations-runbook.md +54 -0
  14. package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
  15. package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
  16. package/bundled-skills/maggie-deployment/SKILL.md +89 -7
  17. package/bundled-skills/maggie-deployment/references/vps.md +38 -6
  18. package/bundled-skills/maggie-design/SKILL.md +55 -0
  19. package/bundled-skills/maggie-feedback/SKILL.md +29 -0
  20. package/bundled-skills/maggie-ops/SKILL.md +37 -0
  21. package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
  22. package/bundled-skills/maggie-service-booking/SKILL.md +59 -0
  23. package/bundled-skills/maggie-social-share/SKILL.md +19 -2
  24. package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
  25. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +51 -0
  26. package/bundled-tools/clis/maggie_deployment.py +58 -17
  27. package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
  28. package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
  29. package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
  30. package/bundled-tools/clis/maggie_design.py +8 -0
  31. package/bundled-tools/clis/maggie_design_verify.py +359 -0
  32. package/bundled-tools/clis/maggie_docs.py +82 -4
  33. package/bundled-tools/clis/maggie_email_localization.py +382 -0
  34. package/bundled-tools/clis/maggie_feedback.py +9 -2
  35. package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
  36. package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
  37. package/bundled-tools/clis/maggie_localization.py +5 -0
  38. package/bundled-tools/clis/maggie_marketing_run.py +272 -0
  39. package/bundled-tools/clis/maggie_notifications.py +528 -0
  40. package/bundled-tools/clis/maggie_ops.py +87 -0
  41. package/bundled-tools/clis/maggie_schedule.py +12 -2
  42. package/bundled-tools/clis/maggie_service_import.py +520 -0
  43. package/bundled-tools/clis/maggie_social_queue.py +104 -0
  44. package/bundled-tools/clis/maggie_social_share.py +6 -2
  45. package/bundled-tools/runtime/google_ads.py +306 -0
  46. package/bundled-tools/runtime/google_capabilities.py +18 -2
  47. package/bundled-tools/runtime/google_remediation.py +152 -0
  48. package/package.json +1 -1
  49. package/references/google-integrations-runbook.md +54 -0
@@ -2,7 +2,7 @@
2
2
  name: maggie-social-share
3
3
  description: Turn approved AI CMO articles and content assets into platform-native social drafts, scheduled posts, and measured distribution. Use for Social Share planning, LinkedIn/Facebook copy, CTA mapping, calendars, approvals, publish retries, or social performance readback.
4
4
  metadata:
5
- version: 1.0.0
5
+ version: 1.1.0
6
6
  ---
7
7
 
8
8
  # Maggie Social Share
@@ -39,6 +39,22 @@ maggie tool maggie_social_share.py adapters .maggie/social-adapters.json
39
39
  maggie tool maggie_social_share.py connections .maggie/social-connections.json
40
40
  maggie tool maggie_social_share.py campaigns .maggie/social-campaigns.json
41
41
  maggie tool maggie_social_share.py queue .maggie/social-queue.json --slots 14
42
+ maggie tool maggie_social_share.py reconcile .maggie/social-attempts.json
43
+ ```
44
+
45
+ A publishing adapter also declares limits, media rules, pending behaviour,
46
+ retry classes (a timeout is an unknown outcome, never retryable), idempotency,
47
+ and readback. `reconcile` turns the attempt log plus provider readback into
48
+ adopt / await / retry-with-the-same-key / fail decisions, so a
49
+ timeout-after-success never creates a duplicate post.
50
+
51
+ When social work is part of a wider campaign, record it in a
52
+ [marketing run](references/marketing-run.md) with named schedule layers
53
+ (infrastructure, content calendar, campaign flight, measurement window) and,
54
+ for regulated content, a review that gates only external publication:
55
+
56
+ ```bash
57
+ maggie tool maggie_marketing_run.py validate .maggie/marketing-runs/<runId>.json
42
58
  ```
43
59
 
44
60
  1. Select an approved or explicitly shareable source article/content asset.
@@ -54,7 +70,8 @@ maggie tool maggie_social_share.py queue .maggie/social-queue.json --slots 14
54
70
  overdue posts). Campaign ideas stay in the idea stage until written, and a
55
71
  campaign starts only through the Start gate.
56
72
  6. Publish only after the required approval and entitlement checks. Record the
57
- publication attempt and provider ID; retry only with bounded idempotency.
73
+ publication attempt and provider ID; reconcile unknown outcomes against
74
+ readback and retry only with the original idempotency key.
58
75
  7. Read back clicks, engagement, assisted conversions, and failures separately
59
76
  from article SEO/GEO metrics.
60
77
 
@@ -0,0 +1,57 @@
1
+ # Marketing runs, schedule layers, and regulated-content review
2
+
3
+ A marketing run joins content, Social Share, Ads, and measurement under one
4
+ project-scoped `runId`. It is a reference record: it points at source
5
+ revisions, capability evidence, approvals, mutations, and readbacks by ID and
6
+ never stores provider secrets or large payloads.
7
+
8
+ Schema: `contracts/maggie-ops/marketing-run-v1.schema.json`
9
+ (`schemaVersion: maggie-marketing-run-v1`). Validate with:
10
+
11
+ ```bash
12
+ maggie tool maggie_marketing_run.py validate .maggie/marketing-runs/<runId>.json
13
+ maggie tool maggie_marketing_run.py layers # print the layer model
14
+ ```
15
+
16
+ ## Run fields
17
+
18
+ | Field | Purpose |
19
+ |---|---|
20
+ | `runId`, `projectRef` | One project-scoped correlation ID across every channel. |
21
+ | `objective`, `conversion` | What the run is for and which event (and source) proves it. |
22
+ | `channels`, `timezone`, `mode` | Channels involved, the run's IANA zone, and `draft`/`scheduled`/`executing`/`completed`/`cancelled`. |
23
+ | `sourceRevisions` | `{sourceId, revision}` of every content source used. |
24
+ | `capabilityEvidence` | IDs/paths of capability reports (for example a Google capability report). |
25
+ | `approval` | `state`, `actor`, `approvalId`; external mutations require `approved`. |
26
+ | `scheduleLayers` | Named layers (below); a run never carries a single `scheduled_at`. |
27
+ | `mutations` | `{mutationId, kind, channel, status, idempotencyKey, approvalRef}`; external publication and Ads changes need an idempotency key. |
28
+ | `readbacks` | `{readbackId, mutationRef}`; a succeeded mutation needs one before the run is `completed`. |
29
+ | `policy`, `stages` | Regulated-content review (below). |
30
+
31
+ ## Schedule layers
32
+
33
+ | Layer | Meaning | Owner | Notes |
34
+ |---|---|---|---|
35
+ | `infrastructure` | When a worker wakes up (cron, systemd timer, queue). | Operations | Points at a `maggie_schedule.py` job via `scheduleRef`; never a publish time. |
36
+ | `content_calendar` | When reviewed content or social posts may publish. | Editor/marketer | Social Share's slot queue; `start` required. |
37
+ | `campaign_flight` | When paid campaigns may run and spend. | Ads owner | `start` and `end` required. |
38
+ | `measurement_window` | Collection and attribution period. | Analytics | `start`, `end`, and `lagDays` for conversion lag. |
39
+
40
+ Every layer declares `timezone`, `owner`, `status`, and a `reconciliation`
41
+ rule describing how its real state is read back. `maggie_schedule.py` jobs are
42
+ always the `infrastructure` layer and reject publish-time fields.
43
+
44
+ ## Regulated-content review
45
+
46
+ `policy.regulatedContent` is off by default, so existing runs are unchanged.
47
+ When it is on, only the **external publication** action waits for
48
+ `policy.reviewState: approved`. Content delivery and pull (including legacy
49
+ plugins), draft creation, SEO/GEO work, and measurement continue; policy
50
+ labels are additive metadata, never a gate in the delivery path.
51
+
52
+ The validator fails when:
53
+
54
+ - a stage other than `external_publication` is `blocked` with reason
55
+ `policy_review`;
56
+ - an `external_publication` mutation is pending or succeeded before the review
57
+ is approved.
@@ -99,3 +99,54 @@ Rules the planner applies, in order:
99
99
 
100
100
  The plan is advisory output for the host scheduler, which re-plans after every
101
101
  publish, approval, or queue edit instead of persisting stale per-post times.
102
+
103
+ ## Publish contract — `adapters` with `"publishes": true`
104
+
105
+ An adapter that publishes must declare, in addition to its scope set:
106
+
107
+ | Field | Meaning |
108
+ |---|---|
109
+ | `limits.text_max` | Provider text limit used by `validate`. |
110
+ | `media.types`, `media.max_bytes` | Accepted media and size budget. |
111
+ | `pending.states`, `pending.max_pending_seconds` | Provider "processing" states and how long a post may stay there before it is treated as failed. |
112
+ | `retry_classes.retryable` / `.terminal` / `.unknown_outcome` | Provider error classes. A timeout is always `unknown_outcome`: the post may already exist. |
113
+ | `idempotency.key_source`, `idempotency.provider_supported` | How the stable key is derived (for example `queue_item_id+revision`) and whether the provider honours it. |
114
+ | `readback.lookup` | `by_idempotency_key` (only when the provider supports keys), `by_marker` (key embedded in the post), or `by_recent_posts`. |
115
+
116
+ Adapters without `publishes` keep the scope-only shape.
117
+
118
+ ## Publish reconciliation — `reconcile`
119
+
120
+ `reconcile` reads the publish-attempt log and a provider readback and returns
121
+ one safe action per queue item. It never publishes.
122
+
123
+ ```json
124
+ {
125
+ "attempts": [{"queue_item_id": "q1", "idempotency_key": "q1:r3", "status": "timeout"}],
126
+ "readback": {"complete": true, "posts": [{"id": "urn:li:share:1", "marker": "#mg-q1:r3"}]}
127
+ }
128
+ ```
129
+
130
+ ```bash
131
+ maggie tool maggie_social_share.py reconcile attempts.json
132
+ ```
133
+
134
+ | Situation | Action |
135
+ |---|---|
136
+ | A readback post carries the item's key or marker | `adopt` it as the published post (timeout-after-success). |
137
+ | An attempt reported a post ID that readback does not show | `verify_readback` before anything else. |
138
+ | Last outcome `timeout`/`pending`/`unknown`, readback incomplete | `await_readback`; no retry. |
139
+ | Same, readback complete and no match | `retry_same_key`. |
140
+ | `failed_retryable` | `retry_same_key`. |
141
+ | `failed_terminal` | `fail`. |
142
+ | Two provider posts for one item, or retries that changed the key | invalid; `resolve_duplicate` (keep the first, delete extras). |
143
+
144
+ Immediate and scheduled publishing share this one path, so a retry can never
145
+ create a second post.
146
+
147
+ ## Marketing runs and schedule layers
148
+
149
+ Social work that belongs to a wider campaign is recorded in a
150
+ [marketing run](marketing-run.md) (`maggie-marketing-run-v1`). The slot queue
151
+ above is the `content_calendar` layer; it is never the infrastructure timer
152
+ that wakes the publish worker.
@@ -566,7 +566,7 @@ def preflight(
566
566
  rollback_passed = False
567
567
  if rollback_evidence.exists():
568
568
  try:
569
- rollback_passed = validate_rollback_evidence(json.loads(rollback_evidence.read_text(encoding="utf-8"))) ["passed"]
569
+ rollback_passed = validate_rollback_evidence(json.loads(rollback_evidence.read_text(encoding="utf-8")), plan.get("retention", {}).get("keep"))["passed"]
570
570
  except (OSError, json.JSONDecodeError):
571
571
  rollback_passed = False
572
572
  migration_manifest = project / ".maggie" / "migration-manifest.json"
@@ -579,7 +579,12 @@ def preflight(
579
579
  data_path = project / ".maggie" / "deployment" / "data-release.json"
580
580
  data_required = bool(migration.get("dataDependencies"))
581
581
  data_checkpoint = validate_data_checkpoint(data_path) if data_path.exists() else {"passed": not data_required, "declared": False, "errors": ["data-release.json is required for a data-dependent release"] if data_required else []}
582
- retention_ok = bool(plan.get("retention", {}).get("keep") in (2, 3, 4, 5) and plan.get("retention", {}).get("prune") and plan.get("retention", {}).get("preserve"))
582
+ retention = plan.get("retention", {}) if isinstance(plan.get("retention"), dict) else {}
583
+ keep = retention.get("keep")
584
+ retention_ok = bool(keep in (1, 2, 3, 4, 5) and retention.get("prune") and retention.get("preserve"))
585
+ rollback_command = (plan.get("commands") or {}).get("rollback")
586
+ # Never advertise a rollback target that the retention policy deletes.
587
+ rollback_consistent = retention_ok and (bool(rollback_command) if keep >= 2 else not rollback_command and not any("<previous-release>" in str(item) for item in retention.get("preserve", [])))
583
588
  delegation = {"passed": True, "state": "not-required", "errors": []}
584
589
  delegation_path = delegation_evidence or project / ".maggie" / "deployment" / "deployer-delegation.json"
585
590
  if require_deployer:
@@ -591,14 +596,14 @@ def preflight(
591
596
  else:
592
597
  delegation = validate_deployer_delegation(delegation_value, plan.get("release_root"), plan.get("service"))
593
598
  delegation["path"] = str(delegation_path)
594
- result["checks"].update({"node_runtime_declared": bool(package.get("engines", {}).get("node") or package.get("dependencies", {}).get("astro")), "start_command_or_runtime": "start" in scripts or "preview" in scripts, "stateless_or_migration_plan": True, "release_layout_declared": True, "rollback_command_declared": True, "release_retention_policy": retention_ok, "data_release_checkpoint": data_checkpoint["passed"], "remote_verification_pending": not delegation["passed"], "deployer_delegation": delegation["passed"], "release_plan_artifact": bool(plan_path and plan.get("release_layout") and plan.get("current_release")), "systemd_artifact": bool(systemd_text and "ExecStart=" in systemd_text and "EnvironmentFile=" in systemd_text and "NoNewPrivileges=true" in systemd_text), "nginx_artifact": bool(nginx_text and "proxy_pass" in nginx_text and "server_name" in nginx_text), "deployment_artifacts_secret_free": secret_free, "rollback_evidence": rollback_passed if environment == "production" else True})
599
+ result["checks"].update({"node_runtime_declared": bool(package.get("engines", {}).get("node") or package.get("dependencies", {}).get("astro")), "start_command_or_runtime": "start" in scripts or "preview" in scripts, "stateless_or_migration_plan": True, "release_layout_declared": True, "rollback_command_declared": True, "release_retention_policy": retention_ok, "rollback_target_retained": rollback_consistent, "data_release_checkpoint": data_checkpoint["passed"], "remote_verification_pending": not delegation["passed"], "deployer_delegation": delegation["passed"], "release_plan_artifact": bool(plan_path and plan.get("release_layout") and plan.get("current_release")), "systemd_artifact": bool(systemd_text and "ExecStart=" in systemd_text and "EnvironmentFile=" in systemd_text and "NoNewPrivileges=true" in systemd_text), "nginx_artifact": bool(nginx_text and "proxy_pass" in nginx_text and "server_name" in nginx_text), "deployment_artifacts_secret_free": secret_free, "rollback_evidence": rollback_passed if environment == "production" else True})
595
600
  result["delegation"] = delegation
596
601
  result["vps"] = {"runtime":"Node.js standalone","reverseProxy":"Nginx","processManager":"systemd","releaseLayout":"/var/www/<site>/releases/<release> + current symlink","dns":"Cloudflare DNS or registrar DNS; verify apex and www separately","remoteCommands":["npm ci","npm run build","systemctl restart <service>","systemctl is-active <service>","curl -fsS https://<domain>/robots.txt","curl -fsS https://<domain>/sitemap.xml"],"handoverFields":["host","sshUser","domain","service","release","previousRelease","nginxConfig","migrationVersion","verification","rollback"]}
597
602
  result["next_action"] = "review VPS host, SSH user, domain/DNS, systemd service, Nginx config, release path, migrations, and rollback owner before execute"
598
603
  runners = sorted(deployment_dir.rglob("release-runner.sh")) if deployment_dir.exists() else []
599
604
  result["release_runners"] = [release_runner_status(path) for path in runners[:25]]
600
605
  result["warnings"] = isolation_warnings + [f"generated release runner is stale or unmarked: {item['path']}" for item in result["release_runners"] if item["status"] == "stale"]
601
- result["blocking_checks"] = ["package_manifest", "build_command", "env_example", "node_runtime_declared", "start_command_or_runtime", "stateless_or_migration_plan", "release_layout_declared", "rollback_command_declared", "release_retention_policy", "data_release_checkpoint", "release_plan_artifact", "systemd_artifact", "nginx_artifact", "deployment_artifacts_secret_free", "rollback_evidence"]
606
+ result["blocking_checks"] = ["package_manifest", "build_command", "env_example", "node_runtime_declared", "start_command_or_runtime", "stateless_or_migration_plan", "release_layout_declared", "rollback_command_declared", "release_retention_policy", "rollback_target_retained", "data_release_checkpoint", "release_plan_artifact", "systemd_artifact", "nginx_artifact", "deployment_artifacts_secret_free", "rollback_evidence"]
602
607
  if require_deployer:
603
608
  result["blocking_checks"].append("deployer_delegation")
604
609
  else:
@@ -608,7 +613,21 @@ def preflight(
608
613
  return result
609
614
 
610
615
 
611
- def vps_plan(domain: str, service: str, release_root: str, node_port: int | None, deployer_user: str = "maggie-deploy") -> dict:
616
+ def retention_policy(root: str, current: str, keep: int) -> dict:
617
+ """One retention policy shared by the plan, runner, preflight, prune, and rollback evidence."""
618
+ if not isinstance(keep, int) or isinstance(keep, bool) or not 1 <= keep <= 5:
619
+ raise ValueError("keep must be between 1 and 5")
620
+ preserve = [current] + ([f"{root}/releases/<previous-release>"] if keep >= 2 else [])
621
+ return {
622
+ "keep": keep,
623
+ "preserve": preserve,
624
+ "prune": f"find {root}/releases -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | tail -n +{keep + 1} | cut -d' ' -f2- | xargs -r rm -rf --",
625
+ "rollbackAvailable": keep >= 2,
626
+ "rollbackUnavailableReason": "" if keep >= 2 else "retention keeps only the active release; recover by redeploying the previous commit as a new release and restoring its data checkpoint",
627
+ }
628
+
629
+
630
+ def vps_plan(domain: str, service: str, release_root: str, node_port: int | None, deployer_user: str = "maggie-deploy", keep_releases: int = 2) -> dict:
612
631
  """Create reviewable, secret-free VPS service and reverse-proxy artifacts."""
613
632
  if not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", domain):
614
633
  raise ValueError("domain must be a hostname without a scheme or path")
@@ -622,6 +641,7 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int | None
622
641
  raise ValueError("node port must be between 1024 and 65535")
623
642
  root = release_root.rstrip("/")
624
643
  current = f"{root}/current"
644
+ retention = retention_policy(root, current, keep_releases)
625
645
  privileged_commands = [
626
646
  f"/bin/systemctl restart {service}",
627
647
  f"/bin/systemctl is-active {service}",
@@ -649,7 +669,7 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int | None
649
669
  "allowed_domains_required": True,
650
670
  },
651
671
  "release_layout": f"{root}/releases/<immutable-release> plus current symlink",
652
- "retention": {"keep": 2, "preserve": [current, f"{root}/releases/<previous-release>"], "prune": f"find {root}/releases -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | tail -n +3 | cut -d' ' -f2- | xargs -r rm -rf --"},
672
+ "retention": retention,
653
673
  "staging_first": True,
654
674
  "secrets": "external environment file; no secrets in generated artifacts",
655
675
  "commands": {
@@ -657,7 +677,11 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int | None
657
677
  "switch": f"ln -sfn {root}/releases/<release> {current}",
658
678
  "restart": f"sudo -n /bin/systemctl restart {service}",
659
679
  "verify": f"sudo -n /bin/systemctl is-active {service} && curl -fsS https://{domain}/robots.txt && curl -fsS https://{domain}/sitemap.xml",
660
- "rollback": f"ln -sfn {root}/releases/<previous-release> {current} && sudo -n /bin/systemctl restart {service}",
680
+ "rollback": f"ln -sfn {root}/releases/<previous-release> {current} && sudo -n /bin/systemctl restart {service}" if retention["rollbackAvailable"] else None,
681
+ # The database owner (e.g. postgres) often cannot traverse a release
682
+ # under a deploy user's home; stream the file instead of widening
683
+ # filesystem permissions.
684
+ "migrate_as_db_owner": f"sudo cat {root}/releases/<release>/<migration.sql> | sudo -u postgres psql -v ON_ERROR_STOP=1 --single-transaction -d <database> -f -",
661
685
  "data_rollback": "restore the matching data checkpoint before restarting the previous code release",
662
686
  },
663
687
  "files": {
@@ -692,8 +716,12 @@ def validate_data_checkpoint(path: Path) -> dict:
692
716
  return {"passed": not errors, "declared": True, "errors": errors}
693
717
 
694
718
 
695
- def validate_rollback_evidence(value: object) -> dict:
696
- """Validate release rollback evidence, including the explicitly safe first release."""
719
+ def validate_rollback_evidence(value: object, keep: int | None = None) -> dict:
720
+ """Validate release rollback evidence, including the explicitly safe first release.
721
+
722
+ With a one-release retention policy no previous release stays on disk, so
723
+ upgrade evidence must say the code rollback is unavailable and name the
724
+ recovery plan instead of advertising a removed target."""
697
725
  if not isinstance(value, dict):
698
726
  return {"passed": False, "errors": ["rollback evidence must be an object"]}
699
727
  errors = []
@@ -713,6 +741,13 @@ def validate_rollback_evidence(value: object) -> dict:
713
741
  errors.append("first release evidence must not claim a previous release")
714
742
  if not value.get("notApplicableReason"):
715
743
  errors.append("first release evidence needs notApplicableReason")
744
+ elif keep == 1:
745
+ if value.get("codeRollback") != "unavailable":
746
+ errors.append("retention keeps one release; rollback evidence must declare codeRollback: unavailable")
747
+ if not value.get("recoveryPlan"):
748
+ errors.append("retention keeps one release; rollback evidence needs a recoveryPlan (redeploy previous commit + data checkpoint)")
749
+ elif value.get("codeRollback") == "unavailable":
750
+ errors.append("codeRollback unavailable contradicts a retention policy that keeps a previous release")
716
751
  elif not value.get("previousRelease"):
717
752
  errors.append("upgrade rollback evidence requires previousRelease")
718
753
  return {"passed": not errors, "errors": errors}
@@ -740,15 +775,14 @@ def verify_infrastructure(units_dir: Path | None, service: str, timer: str | Non
740
775
 
741
776
 
742
777
  def retention_plan(release_root: str, current_link: str, keep: int) -> dict:
743
- if keep < 2 or keep > 5:
744
- raise ValueError("keep must be between 2 and 5")
778
+ policy = retention_policy(release_root.rstrip("/"), current_link, keep)
745
779
  root = Path(release_root).resolve()
746
780
  releases = sorted((path for path in root.iterdir() if path.is_dir() and not path.is_symlink()), key=lambda path: path.stat().st_mtime, reverse=True) if root.is_dir() else []
747
781
  current_target = Path(current_link).resolve() if Path(current_link).exists() else None
748
782
  preserved = {str(current_target)} if current_target else set()
749
783
  preserved.update(str(path) for path in releases[:keep])
750
784
  candidates = [str(path) for path in releases if str(path) not in preserved]
751
- return {"schemaVersion": "maggie-retention-plan.v1", "releaseRoot": str(root), "currentLink": str(Path(current_link)), "keep": keep, "preserved": sorted(preserved), "candidates": candidates, "mutation": "not executed", "generatedAt": datetime.now(timezone.utc).isoformat()}
785
+ return {"schemaVersion": "maggie-retention-plan.v1", "releaseRoot": str(root), "currentLink": str(Path(current_link)), "keep": keep, "preserved": sorted(preserved), "candidates": candidates, "rollbackAvailable": policy["rollbackAvailable"], "rollbackUnavailableReason": policy["rollbackUnavailableReason"], "mutation": "not executed", "generatedAt": datetime.now(timezone.utc).isoformat()}
752
786
 
753
787
 
754
788
  def write_vps_plan(output_dir: Path, plan: dict) -> None:
@@ -836,6 +870,7 @@ def release_runner(plan: dict) -> str:
836
870
  service = plan["service"]
837
871
  domain = plan["domain"]
838
872
  current = plan["current_release"]
873
+ keep = retention_policy(root, current, plan.get("retention", {}).get("keep", 2))["keep"]
839
874
  generator = release_runner_generator()
840
875
  return f'''#!/usr/bin/env bash
841
876
  set -euo pipefail
@@ -899,13 +934,19 @@ wait_for_active
899
934
  wait_for_http "https://{domain}/robots.txt"
900
935
  wait_for_http "https://{domain}/sitemap.xml"
901
936
 
902
- # Keep current plus one rollback candidate, and prune only after health checks.
937
+ # Retention policy: keep {keep} release(s), and prune only after health checks.
938
+ KEEP_RELEASES={keep}
903
939
  mapfile -t RELEASES < <(find "$RELEASE_ROOT/releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | cut -d' ' -f2-)
904
- for candidate in "${{RELEASES[@]:2}}"; do
905
- [ "$candidate" = "$PREVIOUS" ] && continue
940
+ for candidate in "${{RELEASES[@]:$KEEP_RELEASES}}"; do
941
+ [ "$KEEP_RELEASES" -ge 2 ] && [ "$candidate" = "$PREVIOUS" ] && continue
906
942
  [ "$candidate" = "$RELEASE" ] && continue
907
943
  rm -rf -- "$candidate"
908
944
  done
945
+ if [ "$KEEP_RELEASES" -ge 2 ] && [ -n "$PREVIOUS" ]; then
946
+ echo "rollback target retained: $PREVIOUS"
947
+ else
948
+ echo "rollback target: unavailable (retention keeps only the active release; redeploy the previous commit and restore its data checkpoint)"
949
+ fi
909
950
  '''
910
951
 
911
952
 
@@ -946,7 +987,7 @@ def main() -> int:
946
987
  parser.add_argument("--check-runner", help="read-only check of a generated runner's Maggie version and template fingerprint")
947
988
  parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
948
989
  parser.add_argument("--current-link", help="current symlink for --retention-plan")
949
- parser.add_argument("--keep-releases", type=int, default=2)
990
+ parser.add_argument("--keep-releases", type=int, default=2, help="releases to keep (1-5); 1 means no on-disk rollback target, used by --vps-plan and --retention-plan")
950
991
  parser.add_argument("--verify-infra", action="store_true", help="verify declared or installed systemd service/timer")
951
992
  parser.add_argument("--units-dir", help="directory containing generated .service and .timer units")
952
993
  parser.add_argument("--timer", help="systemd timer unit name")
@@ -974,7 +1015,7 @@ def main() -> int:
974
1015
  if args.vps_plan:
975
1016
  if not args.domain:
976
1017
  parser.error("--domain is required with --vps-plan")
977
- plan = vps_plan(args.domain, args.service, args.release_root, args.node_port, args.deployer_user)
1018
+ plan = vps_plan(args.domain, args.service, args.release_root, args.node_port, args.deployer_user, args.keep_releases)
978
1019
  if args.plan_dir:
979
1020
  write_vps_plan(Path(args.plan_dir).resolve(), plan)
980
1021
  if args.runner_output:
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env python3
2
+ """Run declared, role-scoped authenticated API canaries without storing credentials or bodies.
3
+
4
+ Public route canaries cannot see authenticated failures such as database
5
+ permission errors, schema mismatches, or 403s on account and business APIs.
6
+ This command executes a reviewed probe plan with credentials read only from
7
+ environment variable *names*, and records status codes and contract checks
8
+ only: never cookies, tokens, headers, response bodies, or personal data.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ import re
17
+ import sys
18
+ import urllib.error
19
+ import urllib.request
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from urllib.parse import urlparse
23
+
24
+ PLAN_SCHEMA = "maggie-auth-canary-plan.v1"
25
+ EVIDENCE_SCHEMA = "maggie-deployment-auth-canary.v1"
26
+ MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
27
+ ALLOWED_METHODS = {"GET", "HEAD", "OPTIONS"} | MUTATING
28
+ LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
29
+ ENV_NAME = re.compile(r"^[A-Z][A-Z0-9_]{1,63}$")
30
+ SECRET_VALUE = re.compile(r"(?:Bearer\s+\S{8,}|eyJ[A-Za-z0-9_-]{10,}\.|sk[-_](?:live|test)[-_]|session=|sid=|[A-Fa-f0-9]{32,})")
31
+
32
+
33
+ class PlanError(ValueError):
34
+ pass
35
+
36
+
37
+ def classify(status: int | None) -> str:
38
+ if status is None:
39
+ return "network"
40
+ if status in (401, 403):
41
+ return "authorization"
42
+ if status >= 500:
43
+ return "server"
44
+ if status == 404:
45
+ return "route"
46
+ return "contract"
47
+
48
+
49
+ def contains_literal_secret(value: object) -> bool:
50
+ if isinstance(value, dict):
51
+ return any(contains_literal_secret(item) for item in value.values())
52
+ if isinstance(value, list):
53
+ return any(contains_literal_secret(item) for item in value)
54
+ return isinstance(value, str) and bool(SECRET_VALUE.search(value))
55
+
56
+
57
+ def validate_plan(plan: object) -> dict:
58
+ """Return the normalized plan or raise PlanError for an unsafe/invalid plan."""
59
+ if not isinstance(plan, dict) or plan.get("schemaVersion") != PLAN_SCHEMA:
60
+ raise PlanError(f"plan schemaVersion must be {PLAN_SCHEMA}")
61
+ if contains_literal_secret(plan):
62
+ raise PlanError("plan contains a literal credential; reference environment variable names only")
63
+ base = str(plan.get("baseUrl") or "")
64
+ parsed = urlparse(base)
65
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.query or parsed.fragment:
66
+ raise PlanError("baseUrl must be an http(s) origin without query or fragment")
67
+ if parsed.scheme == "http" and parsed.hostname not in LOCAL_HOSTS:
68
+ raise PlanError("baseUrl must use https outside localhost")
69
+ roles = plan.get("roles")
70
+ if not isinstance(roles, dict) or not roles:
71
+ raise PlanError("roles must declare at least one role")
72
+ for name, role in roles.items():
73
+ if not isinstance(role, dict):
74
+ raise PlanError(f"role {name} must be an object")
75
+ keys = [key for key in ("tokenEnv", "cookieEnv") if role.get(key)]
76
+ if len(keys) != 1:
77
+ raise PlanError(f"role {name} must declare exactly one of tokenEnv or cookieEnv")
78
+ if not ENV_NAME.fullmatch(str(role[keys[0]])):
79
+ raise PlanError(f"role {name} {keys[0]} must be an environment variable name")
80
+ unknown = set(role) - {"tokenEnv", "cookieEnv", "scheme", "description"}
81
+ if unknown:
82
+ raise PlanError(f"role {name} has unsupported fields: {', '.join(sorted(unknown))}")
83
+ probes = plan.get("probes")
84
+ if not isinstance(probes, list) or not probes:
85
+ raise PlanError("probes must be a non-empty list")
86
+ seen = set()
87
+ for probe in probes:
88
+ if not isinstance(probe, dict):
89
+ raise PlanError("each probe must be an object")
90
+ probe_id = str(probe.get("id") or "")
91
+ if not re.fullmatch(r"[a-z0-9][a-z0-9._-]{0,63}", probe_id) or probe_id in seen:
92
+ raise PlanError(f"probe id is missing, invalid, or duplicated: {probe_id or '<empty>'}")
93
+ seen.add(probe_id)
94
+ if probe.get("role") not in roles:
95
+ raise PlanError(f"probe {probe_id} references an undeclared role")
96
+ method = str(probe.get("method", "GET")).upper()
97
+ if method not in ALLOWED_METHODS:
98
+ raise PlanError(f"probe {probe_id} method is unsupported")
99
+ path = str(probe.get("path") or "")
100
+ if not path.startswith("/") or "//" in path or "?" in path or "#" in path:
101
+ raise PlanError(f"probe {probe_id} path must be an absolute path without query or fragment")
102
+ expect = probe.get("expectStatus", [200])
103
+ if not isinstance(expect, list) or not expect or not all(isinstance(code, int) and 100 <= code <= 599 for code in expect):
104
+ raise PlanError(f"probe {probe_id} expectStatus must be a list of HTTP status codes")
105
+ if method in MUTATING and probe.get("fixture") != "disposable":
106
+ raise PlanError(f"probe {probe_id} mutates state and must declare fixture: disposable")
107
+ keys = probe.get("expectJsonKeys", [])
108
+ if not isinstance(keys, list) or not all(isinstance(key, str) and key for key in keys):
109
+ raise PlanError(f"probe {probe_id} expectJsonKeys must be a list of key names")
110
+ return plan
111
+
112
+
113
+ def credential_headers(role: dict) -> dict[str, str] | None:
114
+ if role.get("tokenEnv"):
115
+ value = os.environ.get(role["tokenEnv"], "")
116
+ return {"Authorization": f"{role.get('scheme', 'Bearer')} {value}"} if value else None
117
+ value = os.environ.get(role["cookieEnv"], "")
118
+ return {"Cookie": value} if value else None
119
+
120
+
121
+ def run_probe(base: str, probe: dict, role: dict, timeout: float) -> dict:
122
+ method = str(probe.get("method", "GET")).upper()
123
+ record = {"id": probe["id"], "role": probe["role"], "method": method, "path": probe["path"], "expectStatus": probe.get("expectStatus", [200])}
124
+ if probe.get("fixture"):
125
+ record["fixture"] = probe["fixture"]
126
+ headers = credential_headers(role)
127
+ if headers is None:
128
+ record.update({"state": "inconclusive", "reason": "credential environment variable is not set"})
129
+ return record
130
+ headers["User-Agent"] = "maggie-deployment-auth-canary/1.0"
131
+ headers["Accept"] = "application/json"
132
+ data = None
133
+ if method in MUTATING:
134
+ headers["Content-Type"] = "application/json"
135
+ data = json.dumps(probe.get("body", {})).encode("utf-8")
136
+ request = urllib.request.Request(base.rstrip("/") + probe["path"], data=data, headers=headers, method=method)
137
+ status: int | None = None
138
+ body = b""
139
+ try:
140
+ with urllib.request.urlopen(request, timeout=timeout) as response:
141
+ status = response.status
142
+ body = response.read(1_000_000)
143
+ except urllib.error.HTTPError as error:
144
+ status = error.code
145
+ except (urllib.error.URLError, OSError) as error:
146
+ record.update({"state": "failed", "status": None, "failureClass": "network", "reason": type(error).__name__})
147
+ return record
148
+ record["status"] = status
149
+ errors = []
150
+ if status not in record["expectStatus"]:
151
+ errors.append(f"status {status} not in {record['expectStatus']}")
152
+ keys = probe.get("expectJsonKeys", [])
153
+ if keys and not errors:
154
+ try:
155
+ payload = json.loads(body.decode("utf-8") or "null")
156
+ except (UnicodeDecodeError, json.JSONDecodeError):
157
+ payload = None
158
+ if not isinstance(payload, dict):
159
+ errors.append("response is not a JSON object")
160
+ else:
161
+ missing = [key for key in keys if key not in payload]
162
+ record["contractKeys"] = {"expected": len(keys), "present": len(keys) - len(missing)}
163
+ if missing:
164
+ errors.append("missing contract keys: " + ", ".join(missing))
165
+ if errors:
166
+ record.update({"state": "failed", "failureClass": classify(status) if status not in record["expectStatus"] else "contract", "reason": "; ".join(errors)})
167
+ else:
168
+ record["state"] = "passed"
169
+ return record
170
+
171
+
172
+ def auth_canary(plan: dict, allow_mutations: bool, timeout: float = 15.0) -> dict:
173
+ plan = validate_plan(plan)
174
+ results = []
175
+ for probe in plan["probes"]:
176
+ method = str(probe.get("method", "GET")).upper()
177
+ if method in MUTATING and not allow_mutations:
178
+ results.append({"id": probe["id"], "role": probe["role"], "method": method, "path": probe["path"], "fixture": probe.get("fixture"), "state": "blocked", "reason": "mutating probe requires --allow-mutations"})
179
+ continue
180
+ results.append(run_probe(plan["baseUrl"], probe, plan["roles"][probe["role"]], timeout))
181
+ states = {item["state"] for item in results}
182
+ status = "failed" if "failed" in states else "inconclusive" if states & {"inconclusive", "blocked"} else "passed"
183
+ return {
184
+ "schemaVersion": EVIDENCE_SCHEMA,
185
+ "generatedAt": datetime.now(timezone.utc).isoformat(),
186
+ "origin": urlparse(plan["baseUrl"]).netloc,
187
+ "status": status,
188
+ "passed": status == "passed",
189
+ "roles": sorted(plan["roles"]),
190
+ "probes": results,
191
+ "privacy": "status codes and contract-key counts only; no credentials, headers, bodies, or personal data",
192
+ }
193
+
194
+
195
+ def main() -> int:
196
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
197
+ parser.add_argument("--plan", type=Path, required=True, help=f"{PLAN_SCHEMA} probe plan")
198
+ parser.add_argument("--allow-mutations", action="store_true", help="run probes that mutate disposable fixtures")
199
+ parser.add_argument("--timeout", type=float, default=15.0)
200
+ parser.add_argument("--output", type=Path)
201
+ args = parser.parse_args()
202
+ try:
203
+ report = auth_canary(json.loads(args.plan.read_text(encoding="utf-8")), args.allow_mutations, args.timeout)
204
+ except (OSError, json.JSONDecodeError, PlanError) as error:
205
+ print(f"BLOCKED: maggie deployment auth-canary: {error}", file=sys.stderr)
206
+ return 2
207
+ encoded = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
208
+ if args.output:
209
+ args.output.parent.mkdir(parents=True, exist_ok=True)
210
+ args.output.write_text(encoded, encoding="utf-8")
211
+ print(encoded, end="")
212
+ return 0 if report["passed"] else 1
213
+
214
+
215
+ if __name__ == "__main__":
216
+ raise SystemExit(main())