diff --git a/CHANGELOG.md b/CHANGELOG.md index c34feca..639c0db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## Unreleased maintenance + +- Reject ZIP member names that alias during Windows/macOS extraction, including trailing dots/spaces, reserved device names and Unicode normalization collisions; apply the same conservative alias check to output/source boundaries. +- Refuse private environment files, keys, databases, logs and generated dependencies/caches in delivery ZIPs, including ignored local files; preserve the previous verified archive on failure. +- Refuse archive output paths that would replace a bundled document or installer. +- Preserve input validation, row-count guards, audit insertion and cache invalidation in all 20 domain transition graphs, matching their JSON RPC contracts. +- Fix unreadable transition audit rows by declaring status payloads as text; keep assignment details as nullable JSON metadata rather than inserting unencoded step strings. +- Require an explicit nullable assignment deadline and verify failed assignments/transitions leave no orphan workflow or audit rows. +- Fix Git Bash drive-path hangs, reject trailing-slash link destinations, preserve late targets, and treat PowerShell bracket paths literally. +- Reject Windows junctions and other reparse points in bundle sources and source parents before reading files outside the catalog. +- Validate manifest paths and SHA-256 records before hashing; reject drive paths, alternate streams, traversal, generated secrets and linked source parents. +- Clarify shared operator access and assignment metadata, without claiming per-owner isolation or dual-control approval. + +These changes do not replace the frozen v0.5.2 release assets. Compile the updated graphs with the matching maintenance Editor, whose compiler preserves row-count guards and invalidation arrays. + +Existing deployments that created the generated audit `payload` as a native PostgreSQL JSON column need a reviewed migration to text before adopting the updated configuration. Editing a resource declaration does not alter existing database column types. + ## 0.5.2 — 2026-10-11 - Add TaskBoard atomic batches and a permission-preserving read-only cursor feed with nullable due dates. diff --git a/EXAMPLE_APPS.md b/EXAMPLE_APPS.md index 097f188..84ba81c 100644 --- a/EXAMPLE_APPS.md +++ b/EXAMPLE_APPS.md @@ -29,7 +29,7 @@ The catalog contains 25 named projects. The original five demonstrate specialize | `ResearchVault` | Dataset stewardship, access policies and provenance | | `HiringPipeline` | Candidate workflow, recruiter ownership and audit events | | `IncidentCommand` | Incident response, runbooks and realtime command updates | -| `FinanceOps` | Synthetic dual-control batch operations and totals | +| `FinanceOps` | Synthetic batch operations, scoped operators and totals | | `EditorPluginRegistry` | Forge-backed Editor plugin metadata, permissions and SHA-256 review state | Except for the explicitly public `PublicCatalog`, each project uses a generated `_BOOTSTRAP_ADMIN_KEY`. Run `forge init`; do not hand-author shared credentials. diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 3ceffc4..5880152 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -13,12 +13,12 @@ d0a53d7732fa29299125f15146bdde0446514287c1a4932ff238c094ffbb7c47 .github/releas 02e735b3dfe1c32833eb550b7ff8ffa17f5f2bc3fa1e7bae61a8f5a3883ce398 .python-version 09094d533067dddbaf48b9c021ec594292ddc875e8f93735e44dcff9ab62bf55 AI_USAGE_POLICY.md 759da1adabb4d60f41e3b678452af42192ee3a2c9cc40b0d30522153df374708 AUTHORS.md -2d193cfca8f5387ca602044b5778ddbe14a83e9e5e65c4c5938c913337dc1f69 CHANGELOG.md +ec6e0ca143fdb64391c76dcadca80b9454820090a88a771ec227d118ef05c9d6 CHANGELOG.md 9168a68e101f6221181073456aaad1a0b8aea70efd585c23c4115bd6bd617687 CITATION.cff ea87ab99ee7120f85aba29aa86365691dbeb12a1c15ace2b07bd4cbf90942af2 CODE_OF_CONDUCT.md bc28466868837b058dcbb02e010cedd24012bef91a4a2cc0b45b39663f312773 CONTRIBUTING.md 8ddc6cc3010510fdc12fe7e7203a49f1f6a772c37c9e6d867d2f76e527d710c5 CONTRIBUTOR_LICENSE_AGREEMENT.md -67066c2210511b0e6c5e0a6f2d990b2c4fcd8561e9bceee2cbdc53b89334a204 EXAMPLE_APPS.md +958342d5e402c82697304000744d0dc304532ce2cd9281c539b52788acfcceb3 EXAMPLE_APPS.md 1ebf3402b965e3b8a0d50d1e66c520dcdf9d3235eb5cc4ac451528cb5e80bd28 GOVERNANCE.md efeb6b66452bc9ebe1333e7ef29ba0ffaca4f2dbee9537895eb278b20e56dcb2 LICENSE 93f4de799896213b79bba6ba2ea9ae844a3c3cd767480b727ea0460ed8b9c2d5 LICENSE-FAQ.md @@ -26,7 +26,7 @@ ce84a06b898c79cd6199e9eac425e40a3b8f7f8a346d6bbdd019580de56d7652 LICENSE-HISTOR fe685a1d2336b057e4e7c8f8fca53f57d6ff517c336b18e6dc6517e65abda111 LICENSE-METADATA.json 8e9754c8f30a66a580e57a0fe61937ee9d0c37c5d56a0087dfb78761586f0c16 NOTICE.md c48cc9fa65e5969c12a670a25a7c712c998f0c8898e0de2906d58713ac4acdae OWNERSHIP.md -374ac8f4699debabee5780aefdb90efa28ef7e47a7cac140765f9435a8fd9371 README.md +e2d0b423ecabccb3d289a848ba7ada7c53f1835fee035035edead2065752501c README.md b2deec1da04c432b0e7e6005990c650b6e993f9fd5aea9efac7910ad29797983 RELEASE.md 941aa530eada55c8f5d453577cbbfb2e8b1ff896f896d14654f0e63a29c03988 RELEASE_ASSETS.md d15347c85b93c932d609cd47d86304e26a32653504f76f4aa5b426db94845f17 RELEASE_CHECKLIST.md @@ -35,69 +35,69 @@ d15347c85b93c932d609cd47d86304e26a32653504f76f4aa5b426db94845f17 RELEASE_CHECKL 06e2acd8265bf3377d8d78d36f97e56831af9f21d7b3e6e5e5007fce6911a192 THIRD_PARTY_NOTICES.md 99c04048ac6a922ac7ebe0a18061220204647b138b58ea322f8a4f5274eaa163 TRADEMARK_POLICY.md 26e0ab83aa1ff6bd0ae3882787a92663aa19db128e1241ffc99375fb9ca0c121 VERSION -4d71745aed3338ef253667be272ffab0e318dd90f5b5759223b8f30478444ae8 app/CivicPortal/README.md +491cd735f9fdc3168afa0938ca80cbcffc885b6c94966ea372b40985cda8ca32 app/CivicPortal/README.md 4e1caebb4d5a9113872ff9c5022b6982ace1ede28450381694090abca028e280 app/CivicPortal/app.json 5c54c28c91ac4f0d9df0e4e0bc3634b04990a02cc33e45cd8d415eb9b70d79a6 app/CivicPortal/config/10-databases.json b7f3fba49185a912d6651fcc21195f54c8873ba572fc0f702c9504685b1674ad app/CivicPortal/config/20-security.json 65de943ca6a2cb8b1733e4a0215be53cad8f071ef01103868dad4ab77253a09f app/CivicPortal/config/30-runtime.json -938d93d33078334f90be76c7c11f0b9833b54d91696771b8515a7af55aae831b app/CivicPortal/config/40-resources.json -367e7a58f99920ffc656428316e02d99d2b32a6769ec482f2583229cf3c22c8d app/CivicPortal/config/50-operations.json +39c51df134e54729ca13fa39a260fce1dd08f619ab63c291218786491f4c9ce4 app/CivicPortal/config/40-resources.json +a6fa5afdfd92659170e40b1279111234f6e030e5a0707f482c216dfa0aba8f39 app/CivicPortal/config/50-operations.json b4eece6e28528dfc3db37542158d972f86c5fc0dacfab6dc12707034b404d6c4 app/CivicPortal/config/60-events.json -38e466080faa8fd9d9e35cb5235e649bb15a56e4e8551bbe846a11ffd02712f8 app/CivicPortal/graphs/domain-transition.forgegraph.json -287e685c40960019b03329c5e89882e749ec78c27d297f6220d5927fad151294 app/ClinicFlow/README.md +5a15c73024ad5d5de3a0af0529b34d86fb5b00ccd1a1c9879975dbc063c8e6e4 app/CivicPortal/graphs/domain-transition.forgegraph.json +def287100e24bdbdfd81137ecbcba6d9db370f652eb665db5a094ec6d55197df app/ClinicFlow/README.md 62a78c9618e975b5e4cde9966b37c9deea2f61e46888d8656c4bce341fb7f26e app/ClinicFlow/app.json 0bbd551689bdc2e1a393950f6d023a4536a34269a3d60b8fed7bb2badc858d05 app/ClinicFlow/config/10-databases.json f570b998899490168b34790506d9b965813ed8c369afed6d10521d66506fc385 app/ClinicFlow/config/20-security.json 1a23ede5c8bf37785d0bb1e097480355d903c74dce8a6af1cb4d3189078b8df1 app/ClinicFlow/config/30-runtime.json -240eb9fd9821c21ce1674ac8fec84b302283ec076668e853ec4b446fcadc1678 app/ClinicFlow/config/40-resources.json -bde79e61fd0e70bcf73bd878fea2f03452bda8f3a794a0196a1c834de64889cf app/ClinicFlow/config/50-operations.json +654381c9fe472620d52d1ef7e043c47dfef40b25f774764c94c3fd86d82d8f07 app/ClinicFlow/config/40-resources.json +dc448da88d1431d3c3fff964cbb7f2ca887aa9cbc9540814603e6e1b3a72093a app/ClinicFlow/config/50-operations.json 3f34319bc416e1c0c10ac559b71ac23368fab421ba6aaa0fe151e9393563f89b app/ClinicFlow/config/60-events.json -910dc8a52c6a8aa5b28cae065ad065f9a4af2c55852c6eb6a13b7e525fbdd786 app/ClinicFlow/graphs/domain-transition.forgegraph.json -5108cef15ec93e78ff2d4c277ebb927e67d885a83effd5862b2986d52933f4ff app/CommerceCore/README.md +a671af3825d83fb3261da8c849cd66346afa59e92c74def7331d68d007d5ca79 app/ClinicFlow/graphs/domain-transition.forgegraph.json +ac078fcbc4f339b32d70ac3a2ec523aed212565cea02648c4d2ccedf67f44b83 app/CommerceCore/README.md 644749b5b1c66c7a9a108c56a164bf49dfb4bd6bbc606d148df575732864644c app/CommerceCore/app.json 96f8ea6c2a8fd9355186c9a866f89c110296e314e966d8e5eed16e7794be08ef app/CommerceCore/config/10-databases.json f23e79280cf2b39ffb9a03adc2d4f15a86941b8d26909cd5bca40136fb83d418 app/CommerceCore/config/20-security.json 7fd58d3c456f9b0c7936a3798eeb13454a74195b7b67ef999fe71db537aeeadd app/CommerceCore/config/30-runtime.json -64949e2abfdfb985d5eb8c9aa5fa5d7c8d94e212265ed16efc681496ee2d095a app/CommerceCore/config/40-resources.json -82c069fa12a2bf02cef2a7710721902931a8cae4aa8932f30d6b6c09acb3a1fc app/CommerceCore/config/50-operations.json +63a5e724e24eb3de4b5abc1313a3c896ad143e39907a776755bfc2323a941b24 app/CommerceCore/config/40-resources.json +8b19debe9fc95b98423925ff320e4ab768ec74f2c1996936d83803adc76c4cf5 app/CommerceCore/config/50-operations.json 59e5b7974cbafa5c96b7b797de3ba5c5a01317100eba4dfbab8e8ef972f7d418 app/CommerceCore/config/60-events.json -8f96cf0c8778489a18379e886d5e156c3ba7b9a09ef1e6b5245d0b85a2243bee app/CommerceCore/graphs/domain-transition.forgegraph.json -e08025c8a02d02166e6b3829a5c200e17f8f0ce78fbc577622017bb8a5eb84fe app/CreatorStudio/README.md +5ebe559812c63fda0cbd2502f56571c643d991450b1d55c076e9e0d29dfe65bf app/CommerceCore/graphs/domain-transition.forgegraph.json +2750bf23cd2e020e80484c1c7c52094cd556e33b5f5cea2454b9f73f80dcf6e0 app/CreatorStudio/README.md 7151b89c38ae7760f38572d21b986a268df9055104137ee798f28182ed0b8bdd app/CreatorStudio/app.json 415a086f6f61db2a5dbe84a4d40759be62188de9b2bc8f503206efb37e1976be app/CreatorStudio/config/10-databases.json 98a57bb3dc2145b2f8faec86ebc1020321b98e6bb77de03df7210e6e45f91e51 app/CreatorStudio/config/20-security.json a62369c289af74c1c48b8e634d5fac5d002eca39c019ed6b8c461e86a50b116d app/CreatorStudio/config/30-runtime.json -52bdeb2b22bfeb595354a4438b5962360ff77e172252e33dbda547eea4cf82d9 app/CreatorStudio/config/40-resources.json -7edd4ed54f60ce30c64213e0e8ab13fe3e89d876ac64a3fb79f9a4cb72e446d1 app/CreatorStudio/config/50-operations.json +e7e3d50b27d99ab9922276ddb2ebfc168528ff198a52a2b218fbb8a4b20adddf app/CreatorStudio/config/40-resources.json +497a905e250d4f75e2f86c164c2c5d259191b444d473d042afa49fb5de1ae2af app/CreatorStudio/config/50-operations.json 12dae315e6d25ec7623fa32bdbbe8172c838cc651338d8941c9b0e3920de6934 app/CreatorStudio/config/60-events.json -c5cefd4fc7ad358281478482a37d0e8cabb165fe236688a9d613bd33324a2c57 app/CreatorStudio/graphs/domain-transition.forgegraph.json -b5f6bb02384e8b80f83d6c90a43545ccbc5821fa0cfb418dc5a41c1900ce7de5 app/EditorPluginRegistry/README.md +c8a6dd2893a7e13ad8d6219ef598303c4fd65dc8654c9e3d1c5021b3f29b1730 app/CreatorStudio/graphs/domain-transition.forgegraph.json +53974ffdefaa7185fcb1631c530fa6aa05ed882097ce3d1c15f73f8d31e6b041 app/EditorPluginRegistry/README.md 4fa97c4ad4a804049c231689b21046178ca838e76605b9178f926ed1c4c8c6a3 app/EditorPluginRegistry/app.json 821248d7839e93ef59fae189f1d6a005885bb362237055aee02dd1a345b03bd4 app/EditorPluginRegistry/config/10-databases.json fa5c570cf6354cf1bcd152f3c323f4ef25d25cf164d13e586750b11bccd358c8 app/EditorPluginRegistry/config/20-security.json c8180130ad0d181f040bbcca5da183121d12c23fe28c7146f04a5b0542debcd6 app/EditorPluginRegistry/config/30-runtime.json -3e882a648bc96248a7367601adb35a5d41669200e7ea17fb4b5c1e47c71fe5a3 app/EditorPluginRegistry/config/40-resources.json -cc39e598d1fe49aa0b42e8b6f576796c36541c2759ab70b29a912f39a8933091 app/EditorPluginRegistry/config/50-operations.json +da35dc04cbc029177f71510d650c27f0c351695d2739a80a7fa7e689149b1efc app/EditorPluginRegistry/config/40-resources.json +9b6c0fd223ae8f2e8505bcd6b93991c78ffcb8268ac3d911e4aab8b3d2deb275 app/EditorPluginRegistry/config/50-operations.json 7058fddeb251c0efc43ada4e257898fe0464b9bcdd3ec1b84715f8cfb92eea42 app/EditorPluginRegistry/config/60-events.json -b71788259d32fc61ce6d9692493a5dabe06b9cc792af882b399e9b62250e3969 app/EditorPluginRegistry/graphs/domain-transition.forgegraph.json -8a10d7415983ba143091f442115fbe0f05b0179c81ec062664c6917144376cb1 app/FinanceOps/README.md +230c8acf52234b420187146613e07ebfdac368820ddc9cb6688470dd1cf22ba5 app/EditorPluginRegistry/graphs/domain-transition.forgegraph.json +82ace9ea586461b77d3f9e417094d0fdd66ab1456aa2241341c7197c014ebd0b app/FinanceOps/README.md 20a93b899509eec91c9517361e7ad9f3078f06e9a7f5b1fdec4661bd43ce29c3 app/FinanceOps/app.json 2e3904801ef46200d9bfef45604d97ab0a304ddc888a567ec2b731ad02b50c53 app/FinanceOps/config/10-databases.json 5564d350effc8f12179eed208e89d785764a45e3669d03297d034b16d1c28ae5 app/FinanceOps/config/20-security.json 31e1db60eca92373779ce655aa3840f5b2180b17d77614c158af9f7368200e5d app/FinanceOps/config/30-runtime.json -5b0b6da5dede55ffeaa312de7bd4e9855a4d2dbe0db526224be1944d4c233d5a app/FinanceOps/config/40-resources.json -665f67869815b84a2bde448c42e6e2dd37f80d0e78b50ed8883a063d34766b30 app/FinanceOps/config/50-operations.json +6e789df6200ac2270910762814342fbdbc95362383148b36249bc27508e7a599 app/FinanceOps/config/40-resources.json +8c96aa60dd57b3eca7a158bfa52ea087b7346b1837074379e19e3863ac54774d app/FinanceOps/config/50-operations.json 4c887b0058ecc26905045b06f28638c2ee0b8460a06f4c7e862e66e95b0c8970 app/FinanceOps/config/60-events.json -162b91e21ff9b00f7953b8fc74875d07e99838021618f9dc0b65b7ff267eecd7 app/FinanceOps/graphs/domain-transition.forgegraph.json -6c20b46d333fc0edf4fc7ce57bd987e75ab279fc2350adc2a1f8e2d6a10ea702 app/FleetControl/README.md +9a8e4f6fb1de5e88829c7800b954420608d15eede02c1c3dc2c5ef56ba7e0147 app/FinanceOps/graphs/domain-transition.forgegraph.json +874f3ebba88d5cebe802e36cb36b320e20f7b61c1361ad61a6061d2ff5d4d0c1 app/FleetControl/README.md 57d79db32a2aba13e5c511aa185773eeff50e71954ce47fe82c6ff5611050514 app/FleetControl/app.json 43bd1784780be06a5a18c171aabd0af38dcb56c8f764f990f99d18a3317b6bfa app/FleetControl/config/10-databases.json d4f84295ece778e7aae90b14f4309e9c1dff40fa500a5bf8942ef8fc0ef9c600 app/FleetControl/config/20-security.json 664300446bf79c7317e144518ae51e3ada5554f21e15a29344ace4193b06b02d app/FleetControl/config/30-runtime.json -c69dc7a6faa427311fd5c33fe9edcb6359f5f5eff5f697e607dec54ac1da2f8f app/FleetControl/config/40-resources.json -b445285a5143baededb3e4d9764baccf3af61637608ff8f8ab2e4b717bdb516c app/FleetControl/config/50-operations.json +f569fb98c047e56c1a36c195d0b8a729d2acace3d48976c6f13dd2f2a199318c app/FleetControl/config/40-resources.json +26c0140439fe15b81b17238131f8d2114e505131bb59d5d5dd8c75fc8e768eb2 app/FleetControl/config/50-operations.json e2acc888e0f624c40ba7ad202d8327ed27170699258d0c617088dad98007320b app/FleetControl/config/60-events.json -2cae0a35a73086836fb4590a07bf2ca2fafd04d349d1600ff22673b04a88c200 app/FleetControl/graphs/domain-transition.forgegraph.json +70bca50ebe71ac1abdac29dec3c1efe117fb8419c29b7741e3b210b4f2a615fd app/FleetControl/graphs/domain-transition.forgegraph.json 3f4635e6d2eae288dc65cff0bf3eac4d240db5517d42efb90d1da0d53b500db8 app/GuildLedger/README.md 8212527a322d5a8016dd547338044156620d02f1d8d32f9627d4195727137927 app/GuildLedger/app.json 70ec5c843297b1c47adcf10812bc735de8d9e59173bad5e2dcad5ce834400c87 app/GuildLedger/config/10-databases.json @@ -105,69 +105,69 @@ e2acc888e0f624c40ba7ad202d8327ed27170699258d0c617088dad98007320b app/FleetContr 572b5df98e3be6eeb6e64a858d8b093e9fb8114c85565eb9075795f26eff4717 app/GuildLedger/config/30-performance.json ae34d7b0167d45cfabe54149657f7f5695e07f92fcd7a53a25f7da142414303c app/GuildLedger/config/40-resources.json 48f98de67b65d9038164e8902694939fb08fd4278d5ed6f608680fef7bac65df app/GuildLedger/config/50-operations.json -23951b0ba488dd24b121b4f23e4be876f1191f1dcacf6a38f4264c91f9b3c774 app/HiringPipeline/README.md +a87b923bc078d82d71c8df9a8ef789eb53a69eda3c2779bc8e7988a35325e434 app/HiringPipeline/README.md f41d93399e74c189bc9fcb0852786469ab78e342961f4c385bc5c9c03c35e10d app/HiringPipeline/app.json b78b9e3203ae23a39170008d0182013746d7b077f5e1fcddc427c371112684a1 app/HiringPipeline/config/10-databases.json d7287c74c7be78ebedbe4a635cb26ac9e68bc49bc3846f2591bd945cae4a7b36 app/HiringPipeline/config/20-security.json 0c19b85ee3f084411c05ca08c546824b6249fe742581743518f3260cf81bcf30 app/HiringPipeline/config/30-runtime.json -7b276bb35fef873435b91d3ecfcbdfa970ba99775a4ac6fa9bdc899d43dd73e6 app/HiringPipeline/config/40-resources.json -897713b6bdcfe24f2e8de247450adf527513ba140fb14c6aa18b38ad6421ae20 app/HiringPipeline/config/50-operations.json +3e41f4b53418441109bbe5410cfb23002603452b9fc8ea0978e62d584a105f68 app/HiringPipeline/config/40-resources.json +5c064c221c6593373a7c0adbdf9bf825933812b48b00a8f1eba172776e6214f0 app/HiringPipeline/config/50-operations.json b996683feb5fd5946975d647445788832becf8cd3f65d5a3a8f71c27c111242b app/HiringPipeline/config/60-events.json -8e057e819da662885179135308075d015f578f4caa933c54ac7b49a92129e8c2 app/HiringPipeline/graphs/domain-transition.forgegraph.json -6bff9a2b83ec845bc0525811e9cc6375a30e9d5787b14f92475a523e565416ed app/HotelOperations/README.md +341528e9cd42dd67e0248da0437d58093c7f0c9e2c06254cdf07126f9a51a0d5 app/HiringPipeline/graphs/domain-transition.forgegraph.json +c068a2d898a2341b30080cb5bc0c693e4a5309a9569c5d144c28685e4a3207e0 app/HotelOperations/README.md 9af2fb043536fcb1536d1d902c8234604f4a1e22e4ff608e1175ec6a28f3f409 app/HotelOperations/app.json f02f58058b47d276297118b2e6abebb06c2cf1d8f14ca31a911f80bc6a6f7c68 app/HotelOperations/config/10-databases.json 78cdd6d94b586e8ac49565af6acf445c871c60c50f7c9cba6f936e165d95843c app/HotelOperations/config/20-security.json 8fa13bb1ec2b490f17e7bb61f3802081dbfa96a3419d839e65626e9204c3d495 app/HotelOperations/config/30-runtime.json -a15ce2d0e34dd0179ea5cb8c5748018ab4aed1e21d9102c04c916acb419abb33 app/HotelOperations/config/40-resources.json -b4b43cb4eb3fcaee863d66d75823b36d04f5fa7136983a31a425da60f39cc98b app/HotelOperations/config/50-operations.json +86b584e24e1fde92702ae9a36661206e2164263553c04081dba941c0d2554502 app/HotelOperations/config/40-resources.json +1d0a751494fb02ee8241034745569d3cd915bca54e29c535e6118ad1311e569e app/HotelOperations/config/50-operations.json 8c9e4a94d5f3988039302b2c9b3f664cab1b02b43245fb2a68e2fa1f24a02888 app/HotelOperations/config/60-events.json -0a3c36fef6b66cd09972045b3ebaf100ddce6001ccf32f1f07e3f0c295a74ab6 app/HotelOperations/graphs/domain-transition.forgegraph.json -c72e4bf46181871b36ae0db22a00444b4f282b59f01f4b5d59e6c0c5b73fcd12 app/IdentityHub/README.md +f17142ce04803d616c0481272d449cb5807ee75ed55bb2452099504ec81cf5b7 app/HotelOperations/graphs/domain-transition.forgegraph.json +db9a89a87d2bb37f2b7a9cfeb0ea8818b02ae5d57cb17780ca2953df26ebfe65 app/IdentityHub/README.md 0162edba3a6754e7e5d6db490cfe85abba5cb2545a0e3681ca39dea45e898597 app/IdentityHub/app.json f4a9b89ed0981c202de5645430964fef55339978d84ae4b5d0a6a047cd04814d app/IdentityHub/config/10-databases.json c1fa51d9961346df4da15fd1822631f7ff4992e29cdcbc7abcf484e78cfe8073 app/IdentityHub/config/20-security.json e3f7965306fc3fbf4b02bc116642cd9324ebf1861aec633fcae6826ad3db0725 app/IdentityHub/config/30-runtime.json -a670a6c83957a9fe33bb5991599861fe8fd2660124da73abb98cc31543c5a933 app/IdentityHub/config/40-resources.json -c10eaebf6b04f547f5d2a53cc09b945f50e96c2b2f169a98c96ab9116e2299e5 app/IdentityHub/config/50-operations.json +0cd424766ffd077c09aba9c767bb8b041bc5c4174dc8889dd40b9c5ac4823efc app/IdentityHub/config/40-resources.json +d06d8899b0ed44aeb1b251b641ee7b9f85a55f058c7fcd5cb51e2ac73481be33 app/IdentityHub/config/50-operations.json 207666bc9631db7319b159f0ee1b2ce46a97b7357917e8162088c0dc87cf88bd app/IdentityHub/config/60-events.json -7e4f73e18f6a8173e730d77a868b7aa8da4898d90035ed4450ab0f6f1c1dcb04 app/IdentityHub/graphs/domain-transition.forgegraph.json -1498d1e819f1680fb2d1c65f41f7f1ef578307445bdd51231d67966680fc3665 app/IncidentCommand/README.md +c339c3b676dc853fa6d2b9ed6576582d67c6d2f0344ce7c8aa9f3d84f28c0bdd app/IdentityHub/graphs/domain-transition.forgegraph.json +a46bc812ab67eb79cdc133726b5acf89a2179e3b6528a4a587304cb4f1b23675 app/IncidentCommand/README.md 4a448fc3cafc92af3e3000cb16563d08121568a5a3266d5f9d5788e9aca8d285 app/IncidentCommand/app.json 44fd44eaf72c9ef7e8958992ae5a3abcffc0ed48117e947a500c927ef52d1ad0 app/IncidentCommand/config/10-databases.json 2dacc28518ec220464045212f250e346c80165014c3ec05aac15831d783eb6df app/IncidentCommand/config/20-security.json 138d57369437af3b2e34cc361bf1a8647a1fdeedfd846b55156986f695c79e3f app/IncidentCommand/config/30-runtime.json -46f8070314c9cf55009e06d4b9592637a8a0dc74239276df685a58436b1a3218 app/IncidentCommand/config/40-resources.json -b0cc4f122dbddaef8186c67be144926c8c07d7b3c98ff2a3f3d78e45da6d4707 app/IncidentCommand/config/50-operations.json +63f5001e1450fc64a12891edbfddb36f68a62479365c083fb5020aa8d2e778b3 app/IncidentCommand/config/40-resources.json +0e5127b5253152b78e6d848347ad75cebb5d135c46da4f2fc34586a262e26dd1 app/IncidentCommand/config/50-operations.json cd924ccc21385869c6e02eb5eeba3859f4c696d5d076dace3c466fbe8051baa1 app/IncidentCommand/config/60-events.json -42e948bc09051f93630703578fa511e74f66e04beaf28f5b8148e3110683c6a4 app/IncidentCommand/graphs/domain-transition.forgegraph.json -4555ef97a931c473fe67032c4091bbf9e94e7cb6563a1a2a8dade82e027b52cd app/IoTControlCenter/README.md +52fe4bcd03b53e9c925c0a58ce9fe08e7d70b669c6c607a5a20769247c938412 app/IncidentCommand/graphs/domain-transition.forgegraph.json +f83724b79be442245746b887e943118f9f830795d6fe338e4c20a3a31be00494 app/IoTControlCenter/README.md 32b50addb2d2ca7a491e31ab43d6ff4d02cf7429caaffcad44e5d06121c326fc app/IoTControlCenter/app.json 06fc209f5ec8c8f210f3006a902503e621aedcddad013d8907e5ff922895951d app/IoTControlCenter/config/10-databases.json a6cef1d2674f0de00460595c4b8f57e5879eaeb55ef13bc1589626d559006ab6 app/IoTControlCenter/config/20-security.json 9c09c5af6213b5bea5ce63337518cdf893913ae49e47727c43eb0cbc7c08d4e3 app/IoTControlCenter/config/30-runtime.json -a470a84bf367c9d2b3f7d1e14177acfb58a2bcf493dfd046dac84f2a0977b2f8 app/IoTControlCenter/config/40-resources.json -359db39209254aedd61b5536742d69ab541b7786ffbb2dbc0c140e8e94ad5a89 app/IoTControlCenter/config/50-operations.json +bd1b22569661f33e828c6476c0bbcbed0dfc1741ba3a0910b894bf96f39e0c4f app/IoTControlCenter/config/40-resources.json +135ecde64ef4521ad5be071e5b611d7917bb167c35d4fbb06c5cda653b86c0a0 app/IoTControlCenter/config/50-operations.json 72b9f738baed0a8ae30e02d28fe949a304374979f98cb3b081210c97a21ccb53 app/IoTControlCenter/config/60-events.json -3910bc2508afba307306e0e1207b9a33778f2af22dc0ac3d21d5d2dfe2fe6109 app/IoTControlCenter/graphs/domain-transition.forgegraph.json -b1675be3d0ed8af2aea732657d056077146a85e29b6d32347b0df3a646b0dfc6 app/LearningCampus/README.md +092dbded731f93a03aed9641ade6513e2afca78b61889947ac29cd27a6c84329 app/IoTControlCenter/graphs/domain-transition.forgegraph.json +cbd37a3695ff6bc126cbd6aff9eb16660a0c94eeae95e17eb82b4ccb49b1da1e app/LearningCampus/README.md 3e53d8717ab282fc9075d0f24b60e8b745e810dd2b0e0e4baf0b013dfa615143 app/LearningCampus/app.json 30431b16a390302c93f5e752b23dfc79b7fbab8aa4f64e688f55e4ad47e3c425 app/LearningCampus/config/10-databases.json 0c1cc360cbab2922e208aa995761643ef4a24288e4ed686aa6e7b3d2a8eb3723 app/LearningCampus/config/20-security.json cd06105048c47c42b382bd1f1b32efc662171764516b48fbfbf098e62f745ee0 app/LearningCampus/config/30-runtime.json -0e051490ca7f2089d4a3b0749d7d3577030b3378095fba25176bfc31af9d0c89 app/LearningCampus/config/40-resources.json -98142f67f3949f1295b50d8769b21461b66b0648206444cf7b43aec5df1a8547 app/LearningCampus/config/50-operations.json +61a75bd36d9c4d46a551f9e340d38dd53f467e4645147686ec16e4b32c3f18be app/LearningCampus/config/40-resources.json +5706b3dde75beac5f96da9028b27f7db69c056327bf0a7e276087fd2176c43d0 app/LearningCampus/config/50-operations.json 3505f87014813034ff4d00afefea920e87de3dda6e3fd37a35301d69a1b53db7 app/LearningCampus/config/60-events.json -4027a6ba1000403046ce7d1591d79602af0d2228e74084d43470e5760eac8519 app/LearningCampus/graphs/domain-transition.forgegraph.json -b2df4f3a7c6471daebf9ca272436312caeb6fe5670d6a8f927b974f5a25115a8 app/LogisticsNetwork/README.md +970055d90bc5161e3c853760b398c7bb27579fe876ce05f4344070a66a71d6ea app/LearningCampus/graphs/domain-transition.forgegraph.json +e6fdd51e85338f6adc9de6054aad83ba1c967000026a207cb82fe1261aee003a app/LogisticsNetwork/README.md 4b2d8fd973a190adaa9fb018a8b0f1049f3f7b18ace1d6490a93dec181ca3417 app/LogisticsNetwork/app.json 01ea361028b634c05fe8deba597797d4dd46f50bc6511384300b3e36d47c9851 app/LogisticsNetwork/config/10-databases.json 02d657edabc7bfa37e0024c035b461d32bc5a7f56255a3e582fc1dba55981eda app/LogisticsNetwork/config/20-security.json 94551f172207830525b983b0f234917212a18e974903de9f7df6bb52c0224f7b app/LogisticsNetwork/config/30-runtime.json -284254e1e01c338d40ddc66a802e3219b0b37ea47574ad02abf983c9e07f5218 app/LogisticsNetwork/config/40-resources.json -aed2828c35f6b6db6bd0ce34f06915c6f9ab380aeecae4d1dc4c8a71a3cb946a app/LogisticsNetwork/config/50-operations.json +529919a1a7e95c7a982d00faaa3eae5d2570573e3dcda06dd5864c6b38099074 app/LogisticsNetwork/config/40-resources.json +10c1e719521070d9075c793fa99125521eb298864b319d2d846994e97366b5d5 app/LogisticsNetwork/config/50-operations.json c57d8716bb4ca94bedf774601ccff2d77028206623cc6f47a253b76ce29c4fc4 app/LogisticsNetwork/config/60-events.json -20b370401fd7c63a65164c505241970d56ba702c2e7ddcf02fa2df4b22e1a8ba app/LogisticsNetwork/graphs/domain-transition.forgegraph.json +2f1e40a3f568ffd6a4a0fb3f98d115ccd4992b87b5d7d309d292cd8055363b98 app/LogisticsNetwork/graphs/domain-transition.forgegraph.json 10d21f590f0bb84c9756d7eea9f901b0186e5346efaa8e9eaf8f43ae08772c68 app/MediaLibrary/README.md dfbfb05289531636a218e25f7bf098a8de44d82ace45749456fb51ab3394933c app/MediaLibrary/app.json 74134038cc96dd5f8b4df685fc1de84d825531d6f0074895c856e1517094e6ca app/MediaLibrary/config/10-databases.json @@ -175,15 +175,15 @@ aa28e2b08e6df1f4e99560e650158073876864948a5e2f986de424bd0ad33257 app/MediaLibra 228cfb18f0aa7edb544069be1ceba54e2aff1f10ea5f12ed7a77eb15190c08ac app/MediaLibrary/config/30-runtime.json 796aa41f51dfa7d1b0a6f49b7ae2a7bc3b8d05d8b1d7ed4699a35521c8bdd053 app/MediaLibrary/config/40-media.json 7ea66e22a81da9741c17125458f81ee90ab78ed8594c517dd2ed49da8ee8fefd app/MediaLibrary/config/50-collections.json -79f0a45b509a25cbe73136b26024444b2de24e7e4b24e7ee4f58922c07c63eef app/ProjectOps/README.md +4a4cb709b1ffde2449721879054c03f4c619b15faecc58f667ccef608ab26cb0 app/ProjectOps/README.md 8e0c27bfb7556fb8d5e3b2d15e1dba8053a227c6c12dfc1260335bbb7f51360b app/ProjectOps/app.json 74aef3921dde86ed3147cccb50cf2d3b3be44c4ae67508d03e1e193d1dc4b61b app/ProjectOps/config/10-databases.json 51ee46f617ac67937c6de8ad93439a3e411159ea68cd312e476e4eb8854b3786 app/ProjectOps/config/20-security.json 6f02d57cebe034212cf678aadc42cf136209b5f77c42bd5fb3011132999e73f4 app/ProjectOps/config/30-runtime.json -ff9aa0e6cd9a76221bf604c4b6f3965a7cb5e0254ee9a9fffc3350255eeb8e26 app/ProjectOps/config/40-resources.json -c7f0fad9826ce4a73dbfa2c1618de60f6f3cd618f0dd6a3b7c0b50abe78ab3a2 app/ProjectOps/config/50-operations.json +8506b35d31c2312a92e10863c89ee20c2beca6960c2740314677784ee30d8418 app/ProjectOps/config/40-resources.json +7f0f7e475d407e9c2ed01daeef3fd237afc5e1ed2e89cf59cd0182e6939b4c36 app/ProjectOps/config/50-operations.json 1ac4d57acc5e7a0272bb3e386f3cda76136d455c73e9be76bdb82190a610807e app/ProjectOps/config/60-events.json -bc22783d7b9bb3c74949c30c2db3297c41632f8f3d90ef1f62d9c6d1a1ad6d21 app/ProjectOps/graphs/domain-transition.forgegraph.json +32f7b21f0f641a13682254d6125f79d23ed1c98e03088ad8311d6ca8a09cfa29 app/ProjectOps/graphs/domain-transition.forgegraph.json 0eafaf05d3bb35470ef997986a00b43ee566ba133b887f12b884c23487721928 app/PublicCatalog/README.md 548692713ccfeb56bcd90465fcfbc5d64b0ce46e1ce911492467db9b66574a4a app/PublicCatalog/app.json b4eb9f94d24a6ef8d1f4439fd545d95b086863eae411bd14b4d25860a1e758fb app/PublicCatalog/config/10-databases.json @@ -198,63 +198,65 @@ df2032153d88597291e90795ae5ce4b4d3426cfaec647ae3a9a2119c4eed276f app/RealtimeSu e23ae46d76ac8e73d32ce357cd9640e72364d14c7faa62098ebcd8949f7d27b5 app/RealtimeSupport/config/30-runtime.json 05bc660ae4f8172f5b799f03023ae752318391051238106ec707159e756813cf app/RealtimeSupport/config/40-tickets.json c928c4b226c3d4347e817d7c4fac2d1ddce207f8700e09ce2c74ae948bfb40b4 app/RealtimeSupport/config/50-events.json -e5c477fff13de9d0f2de5f2b0397d1cbcc391fc0fb88bec0c27ddeece012b3bc app/ResearchVault/README.md +d95e3de905a55a36f51de9ae6d377993d1addb8b9723faf687cfc55824527526 app/ResearchVault/README.md c5667aef4b4baaaa52b8eb2c36033f7a4f8779ae090244df7ee6d9beb9cbc91c app/ResearchVault/app.json 056bd8f47847c645e5e319f2216533f1eefdc70bb50a148510d884029aeabfc2 app/ResearchVault/config/10-databases.json 265fb76a9b110ad6c9a308629ed365e769ce9c2ac027990c5812f96f990a76f1 app/ResearchVault/config/20-security.json 638969d173eb46bd971a9ee20f2bfc2fa2bc05440949ce7cdf6a3cb921aa4603 app/ResearchVault/config/30-runtime.json -e8776f5f68e6d86da098cfe1ca8d8aab29fe2c3392b90b47838440f9aa2d6ab0 app/ResearchVault/config/40-resources.json -d427e719bc06cf29811370c7f873d8357567539bcb3a2c04481ab159054fcd40 app/ResearchVault/config/50-operations.json +5e09196dd09a3e7237076b635fecc2a805cf55921382c6b919239c5e929358e2 app/ResearchVault/config/40-resources.json +61260f984248d80b7372d853e9f2c5a2ec7a3e77f8ec56539061d88bb75cc460 app/ResearchVault/config/50-operations.json ddf9c4842e9360b29fd2b6c9297c2fbe0c699dea8175e71c035257d598cb95d6 app/ResearchVault/config/60-events.json -caf4288a9f2cbd7d1c4b133c674f391205c333f2c1d3b0bddf6af5a1c773ce4a app/ResearchVault/graphs/domain-transition.forgegraph.json -32522ffbf6d2cb2d39f103ee972fcb797c0606e31385a4ba73a8577ce444bb10 app/RestaurantNetwork/README.md +9c3d7441ad7db7418e561b3473585101a40cdf616eb166cb783b54c420041e50 app/ResearchVault/graphs/domain-transition.forgegraph.json +ad5b56c7e84f2e383a0f34c2113dc9855fb2c48a051c49f3d5553747cfdab46e app/RestaurantNetwork/README.md fd5ccaf288e0aa0fd016024dfb57608bfd2e2bc043f188ae146328aef39c692e app/RestaurantNetwork/app.json b763b8663e1a70e08fecd6079e333ba230642f8ac7e11739d484d56a13bd43d8 app/RestaurantNetwork/config/10-databases.json 4d792627441081005956117865e701a52c1e5598289b4fb54a817ae615039eab app/RestaurantNetwork/config/20-security.json 99f18135aa9c99927c9b0f868429444484912f080efc40ec5d622fc54a58a532 app/RestaurantNetwork/config/30-runtime.json -34094f51c0c0f314377ed9c89a6347e66772ba79274f1e6c4d64f4554f4c47c1 app/RestaurantNetwork/config/40-resources.json -cb826b24425056a39108396948e8a57aea79a66730d79aff85e8737fdde0b3b4 app/RestaurantNetwork/config/50-operations.json +426332622284001e900b6dcac962254cbd07486f8e23d3b75d1e38b46410c76a app/RestaurantNetwork/config/40-resources.json +682bcc74301bdb1d608c94ff729c0c40ddde2613a4bae65549530eefe228139a app/RestaurantNetwork/config/50-operations.json aab4e23e93070821f800f3f050933828c3457468fa39fcbfc4f697edacf00a2c app/RestaurantNetwork/config/60-events.json -9e7c68726356002f26c70831e48d0429f35120788fc7e2731bac32333c3fcc56 app/RestaurantNetwork/graphs/domain-transition.forgegraph.json -590d23973dacaa558c72aacd9ce071d1bd9106200e72ea29af2682763181162c app/SubscriptionPlatform/README.md +aa05ce3dff591fe29a00989334059e77eeef19286399257aef41f5cf24384b79 app/RestaurantNetwork/graphs/domain-transition.forgegraph.json +04468c9ed25881324ffab23d9650b0fa116fc0918dd39c10d0fca636f10d3b53 app/SubscriptionPlatform/README.md 27ec8c43071a85c9835c715f8af54d463fc1f379e988e87c50c9122a870cbac7 app/SubscriptionPlatform/app.json 356b17900d89dcd50b5ba8275b5d69d6262ed28b394c9e1b07a0a8f670c323a5 app/SubscriptionPlatform/config/10-databases.json a4bf4cd6a6037faf600e8b1ad1f754e9d90fe64e7e0bfd9f057b9862b873e2b2 app/SubscriptionPlatform/config/20-security.json a479779e0e53771d47db54fd3af2b9a963d4a014823cd1d0c56af41ed1590f15 app/SubscriptionPlatform/config/30-runtime.json -cd4d0f22b60bc6ea46edbf1cf8e8ac362f424d5c28d76e45251a7761d50f80de app/SubscriptionPlatform/config/40-resources.json -ad0dd0a2fa142c1c9b77b995627e2abf1828c1cbed88e0a90f89e2cc396be5da app/SubscriptionPlatform/config/50-operations.json +ddc1635c596ce2bcbc7fa7bb9e5e462cd0a139db0be4ee4af268870c7af19790 app/SubscriptionPlatform/config/40-resources.json +5ebf9144fcf88175fd15c750c878b04ba66427ead10cefd4181f4e7f8d97df3a app/SubscriptionPlatform/config/50-operations.json 8399da55c38f11be168f91cb8ddca8437b09e604176164f9175a0ace3587cb00 app/SubscriptionPlatform/config/60-events.json -7e79b241778e1a5b9d72ebf6b2555cd051ec5336cfa232f6dc45380930e96e71 app/SubscriptionPlatform/graphs/domain-transition.forgegraph.json +728ee79a10ad7b6eb3666354cdedb079d3843947ad2800fc885bdb09bf8d664e app/SubscriptionPlatform/graphs/domain-transition.forgegraph.json 519803fbe128d7835047b1133d8516c021c47f6df0fcf0d577209b1be1518fe3 app/TaskBoard/README.md d1d19bf55931533507bfe0591ddc148dfe75c426d998eda31d31adbbf98782d4 app/TaskBoard/app.json 56f8b7b86d3fb519c052c351a67b80b7768c83d474047cb73e4a2567e86c73b6 app/TaskBoard/config/10-databases.json 8e51bb912dc1caebf6015c102489c8e0d54def5081ca4d2d112b62cc0c8f2873 app/TaskBoard/config/20-security.json 590d8437f5df24422aea60e99932ea4db4aa937b113cdde6076077bacfeb2ce9 app/TaskBoard/config/30-performance.json 1713fe47d74551be9c23b169af73546e32b885a2aa306540e6e87af091968e5e app/TaskBoard/config/40-resources.json -dd531d51f73074f83ec5f9468f29b73a3ebb582fb3f8d12dec18d2818e4bb0ee app/TournamentEngine/README.md +ef6fc1dc788102f999b8c9098f18877580ab4242180205153336cf50d253a768 app/TournamentEngine/README.md 7ecf5097876d957d27bc442ec46af71aefe5512ab3edf57df4edf572c8b5eaa4 app/TournamentEngine/app.json 4668f887ae9130c708f6fabbd632434e972f7b48e070cd7d13587f24642035e6 app/TournamentEngine/config/10-databases.json 415d827aef30fbdf92424d33037ba142ff48a62cc34ad48a73ce32b80508bea6 app/TournamentEngine/config/20-security.json f5d89e33f5cc1a12a974a677758574beecfa7ae99b110238f1619a001f59b655 app/TournamentEngine/config/30-runtime.json -832a6fec7ede3976acec4c53bccc1208cbb8f533b6f330c9260db048f2d9286b app/TournamentEngine/config/40-resources.json -b8087ff9bc8c3024f4e86c3b3b1c09b714fc7e652cdee1c2918ccfa19fdc44ce app/TournamentEngine/config/50-operations.json +b7ee51acf982c7c8a3030fd1b1c8ece612ad1a3497809e94f81b66699dc52d98 app/TournamentEngine/config/40-resources.json +064621fe79def3c98175eef051a5d4cd8749f06997eb8b639107ff2b394acaf4 app/TournamentEngine/config/50-operations.json f5faaeec8581df2d2f10ee4899109cb7ec54cc07d2a664aacf3128b8dd88ed0e app/TournamentEngine/config/60-events.json -6d33831dd2930b0996d989477fce657716ae29767c67dd03e84ddefac15750f1 app/TournamentEngine/graphs/domain-transition.forgegraph.json -98999df484c7d63fb7d623e1e646080197a8ad68d8eff1f179d20f822aa6d193 app/WarehouseGrid/README.md +c09184d684c49122f51918f01bf4ef0d7a1de3d3362304c457525a4ab981abeb app/TournamentEngine/graphs/domain-transition.forgegraph.json +1fdb475fffbee93f376aab438bea78d7ec41be23451cb4cd86293715f1a92247 app/WarehouseGrid/README.md 2f2726bb2a2b9c21bb890910a15effea0cd50ec25d3545959ab4427c796f81de app/WarehouseGrid/app.json 60896ccb17639919f4de807cd45bf0aeaf94732ba0ccde5018288f5d0d28bb07 app/WarehouseGrid/config/10-databases.json 615be42051b0fbd51480fc3a9b0d3acf9b1a195dcfd9611adf8208b81b5ecac9 app/WarehouseGrid/config/20-security.json 0df446369b2d5bd450261f8b2fe465fb08646e275ff21ff474e5de049ad5683f app/WarehouseGrid/config/30-runtime.json -ce5bfc0721a225ca689a44a87aea3957a9819d17b1e2a0991acdc7153a98d928 app/WarehouseGrid/config/40-resources.json -7771db750dbd56b68d45ca4dfe4e2d2d22c68d04dcfb29426100936847cd9b76 app/WarehouseGrid/config/50-operations.json +6b69d8229b7c37bae80331c07e1a206cae86af9da2ab714b2d81c11f815d994f app/WarehouseGrid/config/40-resources.json +2dbb07e1d91b09fdfeed59a6dc6d131fc7c65e1f2b4a4a57870ff269cc90e590 app/WarehouseGrid/config/50-operations.json 91104d89598bbf7ebf7c89ff30bd39364fc6e6971f799c8e5b4ff75fcee34396 app/WarehouseGrid/config/60-events.json -eb26f42ed48c1a92bd65631132869b580bed635151bc7d807c7be263f4b8fac0 app/WarehouseGrid/graphs/domain-transition.forgegraph.json +36ba8968147de2d1e23cfca79abbbf501189a57aa82fdfff05b57c0090e82549 app/WarehouseGrid/graphs/domain-transition.forgegraph.json 643e70c040e87af44dabb14325ab5087aa5a8a745162b034a3267591cfe2fcd4 ruff.toml -9e56f46e2c06346ec5db57ab30d4e1f064adfb259a81ca8b4612854726bd45ab scripts/build-example-bundle.py -4a61ee59ad7db060a963fda22bc818a4718100f1f7c599971fd97052ee3312da scripts/check_manifest.py -8bd8154ab06aa75179a2c81dfef0a4cd921971d22db5e94991e0291358d0634e scripts/generate_example_catalog.py -47b00cfdf0619c56b9f0fa3fbb78da11dc8db4a52c4a3710705737fde941cc0c scripts/install-example.ps1 -2ea276f18d53a3931eb71e90e4e7b65283f39190ce28f7ad325e98ab34440292 scripts/install-example.sh -24c52ce403aaf7f1a32c3c2f4fc7cc15d9bd590ea976fbce3c40eef463fb7bc0 scripts/smoke-example-apps.py +ac6599ec436e9cf383cfb9b167544b921c5434baa2652186894e31fc001d3d33 scripts/build-example-bundle.py +e3b3254431758df5b78a816ec1a6adb1b9adb68fd5efc54b33e3fdc2e81fd0ce scripts/check_manifest.py +1df68e1a66c79ad87c6000aef90a2a81250b19251b80486f7cf1ec4acc720077 scripts/generate_example_catalog.py +3ab00f4ccddd2d29de37552c4424e3fe462129c76c2551127526f815b81b3149 scripts/install-example.ps1 +11156ab65b99bb03f454329ca887693f71ee9fc3efcd63f9ba794a706f07f31b scripts/install-example.sh +826bb4fb9e7146e637fe362f700b8fa0a7c0c98a0911065feb361e7eda5a4373 scripts/smoke-example-apps.py 030f0de702f3577f4921767b064259a12593ec2886289dc1a86362fc99e95c5d scripts/taskboard_client.py -d350901904bef9fc4bff30a8175b1386bbdc459aa262b7a2dc0b938581e1f13d scripts/test_release_tools.py +26c6f762380654f8304127cb31a381caf1fa88cf6bf6121406e4a12af60c2c85 scripts/test_installers.py +54cbf579b273a5ee4de6af541582185b595aebccd9a5de23962346184cb37eca scripts/test_manifest.py +cbbb2bc6152e680e9720dc7b976c6f085c6cb6c68c63ae8a9a0b25fe68c2f732 scripts/test_release_tools.py diff --git a/README.md b/README.md index 279d80c..a7410c3 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,10 @@ The branch workflow checks out `main` separately, installs its real runtime, cop `scripts/build-example-bundle.py` produces a bounded, symlink-free, byte-for-byte deterministic ZIP containing `app/`, the catalog and README, legal/provenance documents, the two copy installers and an internal `SHA256SUMS`. GitHub Actions uploads the ZIP and its external checksum; it never publishes a Release automatically. +Packaging refuses local environment files, keys/certificates, databases and their journals, logs, caches, dependency directories and build output inside examples, even when Git ignores them. Remove local runtime state from the source checkout before packaging; an existing archive stays intact on failure. Public `.env.example` templates and project-owned JSON seed files remain supported. + +Delivery filenames must be portable across Windows, macOS and Linux: trailing dots/spaces, Windows device names and forbidden characters are refused, along with case or Unicode normalization collisions. ZIP output paths must also remain separate from source files and `app/` under these alias rules, even on a case-sensitive build host. + ### Contribute catalog changes Update `scripts/generate_example_catalog.py` for the 20 generated apps, then run: diff --git a/app/CivicPortal/README.md b/app/CivicPortal/README.md index d3b72b8..f04f57c 100644 --- a/app/CivicPortal/README.md +++ b/app/CivicPortal/README.md @@ -10,7 +10,7 @@ Public-service back-office workflow with private mutations, case ownership, poli | Area | Included behavior | |---|---| -| Main records | `civic/service_requests` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `civic/service_requests` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `submitted`, `accepted`, `in_progress`, `resolved`, `rejected` | @@ -43,6 +43,8 @@ Use `POST /api/civic-portal/v1/civic/service_requests/_batch` for up to 100 reco Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`civic.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/CivicPortal/config/40-resources.json b/app/CivicPortal/config/40-resources.json index f85245f..46a9446 100644 --- a/app/CivicPortal/config/40-resources.json +++ b/app/CivicPortal/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/CivicPortal/config/50-operations.json b/app/CivicPortal/config/50-operations.json index 2e43a5f..9e2119f 100644 --- a/app/CivicPortal/config/50-operations.json +++ b/app/CivicPortal/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO civic_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO civic_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/CivicPortal/graphs/domain-transition.forgegraph.json b/app/CivicPortal/graphs/domain-transition.forgegraph.json index 51d452c..4d0032d 100644 --- a/app/CivicPortal/graphs/domain-transition.forgegraph.json +++ b/app/CivicPortal/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "submitted", + "accepted", + "in_progress", + "resolved", + "rejected" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE civic_service_requests SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE civic_service_requests SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO civic_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "civic.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "civic/service_requests", + "civic/audit-events" + ], + "invalidate_operations": [ + "civic.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/ClinicFlow/README.md b/app/ClinicFlow/README.md index d1e8d8d..100270c 100644 --- a/app/ClinicFlow/README.md +++ b/app/ClinicFlow/README.md @@ -10,7 +10,7 @@ Synthetic care-operations workflow with strict permissions, assignment queues an | Area | Included behavior | |---|---| -| Main records | `clinic/care_cases` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `clinic/care_cases` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `intake`, `triage`, `scheduled`, `in_care`, `closed` | @@ -43,6 +43,8 @@ Use `POST /api/clinic-flow/v1/clinic/care_cases/_batch` for up to 100 records in Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`clinic.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/ClinicFlow/config/40-resources.json b/app/ClinicFlow/config/40-resources.json index 5d3eeaa..377b906 100644 --- a/app/ClinicFlow/config/40-resources.json +++ b/app/ClinicFlow/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/ClinicFlow/config/50-operations.json b/app/ClinicFlow/config/50-operations.json index 0ddac71..60efe4d 100644 --- a/app/ClinicFlow/config/50-operations.json +++ b/app/ClinicFlow/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO clinic_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO clinic_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/ClinicFlow/graphs/domain-transition.forgegraph.json b/app/ClinicFlow/graphs/domain-transition.forgegraph.json index 9320152..25a67cd 100644 --- a/app/ClinicFlow/graphs/domain-transition.forgegraph.json +++ b/app/ClinicFlow/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "intake", + "triage", + "scheduled", + "in_care", + "closed" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE clinic_care_cases SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE clinic_care_cases SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO clinic_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "clinic.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "clinic/care_cases", + "clinic/audit-events" + ], + "invalidate_operations": [ + "clinic.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/CommerceCore/README.md b/app/CommerceCore/README.md index 14b6334..35e51cb 100644 --- a/app/CommerceCore/README.md +++ b/app/CommerceCore/README.md @@ -10,7 +10,7 @@ Multi-stage order orchestration, ownership, monetary rollups, atomic transitions | Area | Included behavior | |---|---| -| Main records | `commerce/orders` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `commerce/orders` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `pending`, `paid`, `packed`, `shipped`, `cancelled` | @@ -43,6 +43,8 @@ Use `POST /api/commerce-core/v1/commerce/orders/_batch` for up to 100 records in Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`commerce.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/CommerceCore/config/40-resources.json b/app/CommerceCore/config/40-resources.json index 2550f76..955eb4c 100644 --- a/app/CommerceCore/config/40-resources.json +++ b/app/CommerceCore/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/CommerceCore/config/50-operations.json b/app/CommerceCore/config/50-operations.json index 17f4ac0..01f7bb4 100644 --- a/app/CommerceCore/config/50-operations.json +++ b/app/CommerceCore/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO commerce_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO commerce_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/CommerceCore/graphs/domain-transition.forgegraph.json b/app/CommerceCore/graphs/domain-transition.forgegraph.json index dc69612..4e34056 100644 --- a/app/CommerceCore/graphs/domain-transition.forgegraph.json +++ b/app/CommerceCore/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "pending", + "paid", + "packed", + "shipped", + "cancelled" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE commerce_orders SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE commerce_orders SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO commerce_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "commerce.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "commerce/orders", + "commerce/audit-events" + ], + "invalidate_operations": [ + "commerce.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/CreatorStudio/README.md b/app/CreatorStudio/README.md index 283a847..3683ee4 100644 --- a/app/CreatorStudio/README.md +++ b/app/CreatorStudio/README.md @@ -10,7 +10,7 @@ Content production operations, reviewer assignments, publishing workflow and aud | Area | Included behavior | |---|---| -| Main records | `creator/productions` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `creator/productions` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `draft`, `review`, `scheduled`, `published`, `retired` | @@ -43,6 +43,8 @@ Use `POST /api/creator-studio/v1/creator/productions/_batch` for up to 100 recor Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`creator.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/CreatorStudio/config/40-resources.json b/app/CreatorStudio/config/40-resources.json index fd5a73c..1ebb633 100644 --- a/app/CreatorStudio/config/40-resources.json +++ b/app/CreatorStudio/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/CreatorStudio/config/50-operations.json b/app/CreatorStudio/config/50-operations.json index ede85a2..83b70f0 100644 --- a/app/CreatorStudio/config/50-operations.json +++ b/app/CreatorStudio/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO creator_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO creator_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/CreatorStudio/graphs/domain-transition.forgegraph.json b/app/CreatorStudio/graphs/domain-transition.forgegraph.json index 4bb05cd..4538d30 100644 --- a/app/CreatorStudio/graphs/domain-transition.forgegraph.json +++ b/app/CreatorStudio/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "draft", + "review", + "scheduled", + "published", + "retired" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE creator_productions SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE creator_productions SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO creator_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "creator.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "creator/productions", + "creator/audit-events" + ], + "invalidate_operations": [ + "creator.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/EditorPluginRegistry/README.md b/app/EditorPluginRegistry/README.md index 0ba1d18..93d5171 100644 --- a/app/EditorPluginRegistry/README.md +++ b/app/EditorPluginRegistry/README.md @@ -10,7 +10,7 @@ Forge-native Editor plugin metadata, publishers, permissions, HTTPS packages and | Area | Included behavior | |---|---| -| Main records | `editor/plugins` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `editor/plugins` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `review`, `approved`, `deprecated`, `revoked` | @@ -45,6 +45,8 @@ Use `POST /api/editor-plugin-registry/v1/editor/plugins/_batch` for up to 100 re Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`editor.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/EditorPluginRegistry/config/40-resources.json b/app/EditorPluginRegistry/config/40-resources.json index 95ab0db..0bd544d 100644 --- a/app/EditorPluginRegistry/config/40-resources.json +++ b/app/EditorPluginRegistry/config/40-resources.json @@ -464,7 +464,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/EditorPluginRegistry/config/50-operations.json b/app/EditorPluginRegistry/config/50-operations.json index 7a76362..27be4b5 100644 --- a/app/EditorPluginRegistry/config/50-operations.json +++ b/app/EditorPluginRegistry/config/50-operations.json @@ -109,7 +109,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -138,14 +139,13 @@ }, "statements": [ { - "sql": "INSERT INTO editor_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO editor_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/EditorPluginRegistry/graphs/domain-transition.forgegraph.json b/app/EditorPluginRegistry/graphs/domain-transition.forgegraph.json index eda0ef0..dd3cad8 100644 --- a/app/EditorPluginRegistry/graphs/domain-transition.forgegraph.json +++ b/app/EditorPluginRegistry/graphs/domain-transition.forgegraph.json @@ -14,7 +14,40 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "review", + "approved", + "deprecated", + "revoked" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +64,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE editor_plugins SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE editor_plugins SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO editor_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "editor.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "editor/plugins", + "editor/audit-events" + ], + "invalidate_operations": [ + "editor.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +149,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/FinanceOps/README.md b/app/FinanceOps/README.md index 063ea6a..9b559d2 100644 --- a/app/FinanceOps/README.md +++ b/app/FinanceOps/README.md @@ -4,13 +4,13 @@ [![Example checks](https://github.com/YoungLionOrganization/JSON-API-Forge/actions/workflows/example-apps.yml/badge.svg?branch=exampleApps)](https://github.com/YoungLionOrganization/JSON-API-Forge/actions/workflows/example-apps.yml?query=branch%3AexampleApps) [![Source available](https://img.shields.io/badge/license-source--available-555555)](../../LICENSE) -Synthetic financial operations workflow with dual-control permissions, batch totals and idempotent audit transitions. +Synthetic financial operations workflow with scoped operator permissions, batch totals and idempotent audit transitions. ## What this example demonstrates | Area | Included behavior | |---|---| -| Main records | `finance/batches` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `finance/batches` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `draft`, `review`, `approved`, `posted`, `rejected` | @@ -43,6 +43,8 @@ Use `POST /api/finance-ops/v1/finance/batches/_batch` for up to 100 records in o Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`finance.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/FinanceOps/config/40-resources.json b/app/FinanceOps/config/40-resources.json index 98066dc..227887c 100644 --- a/app/FinanceOps/config/40-resources.json +++ b/app/FinanceOps/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/FinanceOps/config/50-operations.json b/app/FinanceOps/config/50-operations.json index 90e78ca..147c971 100644 --- a/app/FinanceOps/config/50-operations.json +++ b/app/FinanceOps/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO finance_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO finance_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/FinanceOps/graphs/domain-transition.forgegraph.json b/app/FinanceOps/graphs/domain-transition.forgegraph.json index b02f96e..df9f226 100644 --- a/app/FinanceOps/graphs/domain-transition.forgegraph.json +++ b/app/FinanceOps/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "draft", + "review", + "approved", + "posted", + "rejected" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE finance_batches SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE finance_batches SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO finance_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "finance.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "finance/batches", + "finance/audit-events" + ], + "invalidate_operations": [ + "finance.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/FleetControl/README.md b/app/FleetControl/README.md index 04df126..664088d 100644 --- a/app/FleetControl/README.md +++ b/app/FleetControl/README.md @@ -10,7 +10,7 @@ Vehicle lifecycle, dispatch ownership, maintenance workflows, cost aggregation a | Area | Included behavior | |---|---| -| Main records | `fleet/vehicles` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `fleet/vehicles` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `available`, `assigned`, `maintenance`, `offline` | @@ -43,6 +43,8 @@ Use `POST /api/fleet-control/v1/fleet/vehicles/_batch` for up to 100 records in Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`fleet.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/FleetControl/config/40-resources.json b/app/FleetControl/config/40-resources.json index 4c5e696..40f8785 100644 --- a/app/FleetControl/config/40-resources.json +++ b/app/FleetControl/config/40-resources.json @@ -327,7 +327,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/FleetControl/config/50-operations.json b/app/FleetControl/config/50-operations.json index 337bb51..a6d35db 100644 --- a/app/FleetControl/config/50-operations.json +++ b/app/FleetControl/config/50-operations.json @@ -109,7 +109,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -138,14 +139,13 @@ }, "statements": [ { - "sql": "INSERT INTO fleet_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO fleet_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/FleetControl/graphs/domain-transition.forgegraph.json b/app/FleetControl/graphs/domain-transition.forgegraph.json index 8f1452a..cd6879c 100644 --- a/app/FleetControl/graphs/domain-transition.forgegraph.json +++ b/app/FleetControl/graphs/domain-transition.forgegraph.json @@ -14,7 +14,40 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "available", + "assigned", + "maintenance", + "offline" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +64,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE fleet_vehicles SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE fleet_vehicles SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO fleet_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "fleet.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "fleet/vehicles", + "fleet/audit-events" + ], + "invalidate_operations": [ + "fleet.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +149,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/HiringPipeline/README.md b/app/HiringPipeline/README.md index 34cfc55..fa1d57c 100644 --- a/app/HiringPipeline/README.md +++ b/app/HiringPipeline/README.md @@ -10,7 +10,7 @@ Candidate workflow metadata, recruiter ownership, structured stage transitions a | Area | Included behavior | |---|---| -| Main records | `hiring/candidates` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `hiring/candidates` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `sourced`, `screening`, `interview`, `offer`, `hired`, `closed` | @@ -43,6 +43,8 @@ Use `POST /api/hiring-pipeline/v1/hiring/candidates/_batch` for up to 100 record Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`hiring.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/HiringPipeline/config/40-resources.json b/app/HiringPipeline/config/40-resources.json index 3c4cb35..f3f8958 100644 --- a/app/HiringPipeline/config/40-resources.json +++ b/app/HiringPipeline/config/40-resources.json @@ -331,7 +331,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/HiringPipeline/config/50-operations.json b/app/HiringPipeline/config/50-operations.json index 6120bf1..4e97a04 100644 --- a/app/HiringPipeline/config/50-operations.json +++ b/app/HiringPipeline/config/50-operations.json @@ -111,7 +111,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -140,14 +141,13 @@ }, "statements": [ { - "sql": "INSERT INTO hiring_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO hiring_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/HiringPipeline/graphs/domain-transition.forgegraph.json b/app/HiringPipeline/graphs/domain-transition.forgegraph.json index a531adb..ed58fbf 100644 --- a/app/HiringPipeline/graphs/domain-transition.forgegraph.json +++ b/app/HiringPipeline/graphs/domain-transition.forgegraph.json @@ -14,7 +14,42 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "sourced", + "screening", + "interview", + "offer", + "hired", + "closed" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +66,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE hiring_candidates SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE hiring_candidates SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO hiring_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "hiring.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "hiring/candidates", + "hiring/audit-events" + ], + "invalidate_operations": [ + "hiring.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +151,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/HotelOperations/README.md b/app/HotelOperations/README.md index 9a8ceba..31bf033 100644 --- a/app/HotelOperations/README.md +++ b/app/HotelOperations/README.md @@ -10,7 +10,7 @@ Reservation operations with room ownership, workflow hand-offs, revenue totals a | Area | Included behavior | |---|---| -| Main records | `hotel/reservations` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `hotel/reservations` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `quoted`, `confirmed`, `checked_in`, `checked_out`, `cancelled` | @@ -43,6 +43,8 @@ Use `POST /api/hotel-operations/v1/hotel/reservations/_batch` for up to 100 reco Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`hotel.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/HotelOperations/config/40-resources.json b/app/HotelOperations/config/40-resources.json index 29c3237..30089b4 100644 --- a/app/HotelOperations/config/40-resources.json +++ b/app/HotelOperations/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/HotelOperations/config/50-operations.json b/app/HotelOperations/config/50-operations.json index 53dc476..1525f96 100644 --- a/app/HotelOperations/config/50-operations.json +++ b/app/HotelOperations/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO hotel_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO hotel_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/HotelOperations/graphs/domain-transition.forgegraph.json b/app/HotelOperations/graphs/domain-transition.forgegraph.json index d5d45a1..534cc64 100644 --- a/app/HotelOperations/graphs/domain-transition.forgegraph.json +++ b/app/HotelOperations/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "quoted", + "confirmed", + "checked_in", + "checked_out", + "cancelled" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE hotel_reservations SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE hotel_reservations SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO hotel_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "hotel.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "hotel/reservations", + "hotel/audit-events" + ], + "invalidate_operations": [ + "hotel.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/IdentityHub/README.md b/app/IdentityHub/README.md index 9f3029f..42f7058 100644 --- a/app/IdentityHub/README.md +++ b/app/IdentityHub/README.md @@ -10,7 +10,7 @@ Identity lifecycle administration, scoped operators, policy records, assignment | Area | Included behavior | |---|---| -| Main records | `identity/profiles` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `identity/profiles` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `invited`, `active`, `locked`, `archived` | @@ -43,6 +43,8 @@ Use `POST /api/identity-hub/v1/identity/profiles/_batch` for up to 100 records i Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`identity.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/IdentityHub/config/40-resources.json b/app/IdentityHub/config/40-resources.json index 0b72cc1..2420918 100644 --- a/app/IdentityHub/config/40-resources.json +++ b/app/IdentityHub/config/40-resources.json @@ -327,7 +327,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/IdentityHub/config/50-operations.json b/app/IdentityHub/config/50-operations.json index 09a9511..ee80a00 100644 --- a/app/IdentityHub/config/50-operations.json +++ b/app/IdentityHub/config/50-operations.json @@ -109,7 +109,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -138,14 +139,13 @@ }, "statements": [ { - "sql": "INSERT INTO identity_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO identity_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/IdentityHub/graphs/domain-transition.forgegraph.json b/app/IdentityHub/graphs/domain-transition.forgegraph.json index 183c0f7..3f98c6a 100644 --- a/app/IdentityHub/graphs/domain-transition.forgegraph.json +++ b/app/IdentityHub/graphs/domain-transition.forgegraph.json @@ -14,7 +14,40 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "invited", + "active", + "locked", + "archived" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +64,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE identity_profiles SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE identity_profiles SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO identity_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "identity.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "identity/profiles", + "identity/audit-events" + ], + "invalidate_operations": [ + "identity.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +149,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/IncidentCommand/README.md b/app/IncidentCommand/README.md index 2fccf5b..24b656d 100644 --- a/app/IncidentCommand/README.md +++ b/app/IncidentCommand/README.md @@ -10,7 +10,7 @@ Operational incident control with responder assignments, runbook policies, immut | Area | Included behavior | |---|---| -| Main records | `incidents/incidents` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `incidents/incidents` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `investigating`, `identified`, `monitoring`, `resolved`, `postmortem` | @@ -43,6 +43,8 @@ Use `POST /api/incident-command/v1/incidents/incidents/_batch` for up to 100 rec Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`incidents.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/IncidentCommand/config/40-resources.json b/app/IncidentCommand/config/40-resources.json index f5639eb..e540830 100644 --- a/app/IncidentCommand/config/40-resources.json +++ b/app/IncidentCommand/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/IncidentCommand/config/50-operations.json b/app/IncidentCommand/config/50-operations.json index c843f5e..fd8afee 100644 --- a/app/IncidentCommand/config/50-operations.json +++ b/app/IncidentCommand/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO incidents_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO incidents_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/IncidentCommand/graphs/domain-transition.forgegraph.json b/app/IncidentCommand/graphs/domain-transition.forgegraph.json index de3ec3d..820b80a 100644 --- a/app/IncidentCommand/graphs/domain-transition.forgegraph.json +++ b/app/IncidentCommand/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "investigating", + "identified", + "monitoring", + "resolved", + "postmortem" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE incidents_incidents SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE incidents_incidents SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO incidents_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "incidents.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "incidents/incidents", + "incidents/audit-events" + ], + "invalidate_operations": [ + "incidents.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/IoTControlCenter/README.md b/app/IoTControlCenter/README.md index fa871a7..1f8dddd 100644 --- a/app/IoTControlCenter/README.md +++ b/app/IoTControlCenter/README.md @@ -10,7 +10,7 @@ Device fleet control metadata, operator ownership, remediation workflows and rea | Area | Included behavior | |---|---| -| Main records | `iot/devices` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `iot/devices` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `provisioning`, `online`, `degraded`, `offline`, `retired` | @@ -43,6 +43,8 @@ Use `POST /api/iot-control-center/v1/iot/devices/_batch` for up to 100 records i Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`iot.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/IoTControlCenter/config/40-resources.json b/app/IoTControlCenter/config/40-resources.json index 0278926..e578341 100644 --- a/app/IoTControlCenter/config/40-resources.json +++ b/app/IoTControlCenter/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/IoTControlCenter/config/50-operations.json b/app/IoTControlCenter/config/50-operations.json index 22ed759..3a74de7 100644 --- a/app/IoTControlCenter/config/50-operations.json +++ b/app/IoTControlCenter/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO iot_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO iot_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/IoTControlCenter/graphs/domain-transition.forgegraph.json b/app/IoTControlCenter/graphs/domain-transition.forgegraph.json index db1560a..b07e056 100644 --- a/app/IoTControlCenter/graphs/domain-transition.forgegraph.json +++ b/app/IoTControlCenter/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "provisioning", + "online", + "degraded", + "offline", + "retired" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE iot_devices SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE iot_devices SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO iot_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "iot.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "iot/devices", + "iot/audit-events" + ], + "invalidate_operations": [ + "iot.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/LearningCampus/README.md b/app/LearningCampus/README.md index ae7ae40..ebc62f4 100644 --- a/app/LearningCampus/README.md +++ b/app/LearningCampus/README.md @@ -10,7 +10,7 @@ Enrollment operations, learning workflow checkpoints, policy versioning and boun | Area | Included behavior | |---|---| -| Main records | `learning/enrollments` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `learning/enrollments` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `applied`, `enrolled`, `paused`, `completed`, `withdrawn` | @@ -43,6 +43,8 @@ Use `POST /api/learning-campus/v1/learning/enrollments/_batch` for up to 100 rec Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`learning.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/LearningCampus/config/40-resources.json b/app/LearningCampus/config/40-resources.json index 0951d2c..038915e 100644 --- a/app/LearningCampus/config/40-resources.json +++ b/app/LearningCampus/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/LearningCampus/config/50-operations.json b/app/LearningCampus/config/50-operations.json index c1c9abc..d4b7de3 100644 --- a/app/LearningCampus/config/50-operations.json +++ b/app/LearningCampus/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO learning_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO learning_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/LearningCampus/graphs/domain-transition.forgegraph.json b/app/LearningCampus/graphs/domain-transition.forgegraph.json index 44fbdbb..606c9d9 100644 --- a/app/LearningCampus/graphs/domain-transition.forgegraph.json +++ b/app/LearningCampus/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "applied", + "enrolled", + "paused", + "completed", + "withdrawn" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE learning_enrollments SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE learning_enrollments SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO learning_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "learning.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "learning/enrollments", + "learning/audit-events" + ], + "invalidate_operations": [ + "learning.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/LogisticsNetwork/README.md b/app/LogisticsNetwork/README.md index 097f076..827002a 100644 --- a/app/LogisticsNetwork/README.md +++ b/app/LogisticsNetwork/README.md @@ -10,7 +10,7 @@ Shipment orchestration, hub assignments, exception policies, idempotent updates | Area | Included behavior | |---|---| -| Main records | `logistics/shipments` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `logistics/shipments` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `created`, `in_transit`, `at_hub`, `out_for_delivery`, `delivered`, `exception` | @@ -43,6 +43,8 @@ Use `POST /api/logistics-network/v1/logistics/shipments/_batch` for up to 100 re Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`logistics.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/LogisticsNetwork/config/40-resources.json b/app/LogisticsNetwork/config/40-resources.json index 69b664d..51e6b07 100644 --- a/app/LogisticsNetwork/config/40-resources.json +++ b/app/LogisticsNetwork/config/40-resources.json @@ -331,7 +331,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/LogisticsNetwork/config/50-operations.json b/app/LogisticsNetwork/config/50-operations.json index 168e9b8..b4e1280 100644 --- a/app/LogisticsNetwork/config/50-operations.json +++ b/app/LogisticsNetwork/config/50-operations.json @@ -111,7 +111,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -140,14 +141,13 @@ }, "statements": [ { - "sql": "INSERT INTO logistics_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO logistics_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/LogisticsNetwork/graphs/domain-transition.forgegraph.json b/app/LogisticsNetwork/graphs/domain-transition.forgegraph.json index 577fab1..6787c90 100644 --- a/app/LogisticsNetwork/graphs/domain-transition.forgegraph.json +++ b/app/LogisticsNetwork/graphs/domain-transition.forgegraph.json @@ -14,7 +14,42 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "created", + "in_transit", + "at_hub", + "out_for_delivery", + "delivered", + "exception" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +66,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE logistics_shipments SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE logistics_shipments SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO logistics_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "logistics.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "logistics/shipments", + "logistics/audit-events" + ], + "invalidate_operations": [ + "logistics.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +151,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/ProjectOps/README.md b/app/ProjectOps/README.md index d69f046..e3b1a75 100644 --- a/app/ProjectOps/README.md +++ b/app/ProjectOps/README.md @@ -10,7 +10,7 @@ Portfolio work tracking with priority, assignments, workflow history, aggregate | Area | Included behavior | |---|---| -| Main records | `projects/work_items` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `projects/work_items` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `backlog`, `planned`, `active`, `blocked`, `done` | @@ -43,6 +43,8 @@ Use `POST /api/project-ops/v1/projects/work_items/_batch` for up to 100 records Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`projects.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/ProjectOps/config/40-resources.json b/app/ProjectOps/config/40-resources.json index 89fcd7b..36531ff 100644 --- a/app/ProjectOps/config/40-resources.json +++ b/app/ProjectOps/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/ProjectOps/config/50-operations.json b/app/ProjectOps/config/50-operations.json index 7d968c7..846b795 100644 --- a/app/ProjectOps/config/50-operations.json +++ b/app/ProjectOps/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO projects_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO projects_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/ProjectOps/graphs/domain-transition.forgegraph.json b/app/ProjectOps/graphs/domain-transition.forgegraph.json index ac60fe3..70cef01 100644 --- a/app/ProjectOps/graphs/domain-transition.forgegraph.json +++ b/app/ProjectOps/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "backlog", + "planned", + "active", + "blocked", + "done" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE projects_work_items SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE projects_work_items SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO projects_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "projects.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "projects/work_items", + "projects/audit-events" + ], + "invalidate_operations": [ + "projects.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/ResearchVault/README.md b/app/ResearchVault/README.md index e521fa0..62e0f13 100644 --- a/app/ResearchVault/README.md +++ b/app/ResearchVault/README.md @@ -10,7 +10,7 @@ Dataset catalog operations, steward assignments, access-policy versions, provena | Area | Included behavior | |---|---| -| Main records | `research/datasets` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `research/datasets` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `ingested`, `curating`, `reviewed`, `released`, `restricted` | @@ -43,6 +43,8 @@ Use `POST /api/research-vault/v1/research/datasets/_batch` for up to 100 records Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`research.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/ResearchVault/config/40-resources.json b/app/ResearchVault/config/40-resources.json index 9b4c414..91dc6f6 100644 --- a/app/ResearchVault/config/40-resources.json +++ b/app/ResearchVault/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/ResearchVault/config/50-operations.json b/app/ResearchVault/config/50-operations.json index 2acafad..2974c2d 100644 --- a/app/ResearchVault/config/50-operations.json +++ b/app/ResearchVault/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO research_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO research_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/ResearchVault/graphs/domain-transition.forgegraph.json b/app/ResearchVault/graphs/domain-transition.forgegraph.json index 94ebf57..9c41ccc 100644 --- a/app/ResearchVault/graphs/domain-transition.forgegraph.json +++ b/app/ResearchVault/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "ingested", + "curating", + "reviewed", + "released", + "restricted" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE research_datasets SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE research_datasets SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO research_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "research.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "research/datasets", + "research/audit-events" + ], + "invalidate_operations": [ + "research.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/RestaurantNetwork/README.md b/app/RestaurantNetwork/README.md index eb4aa75..259e385 100644 --- a/app/RestaurantNetwork/README.md +++ b/app/RestaurantNetwork/README.md @@ -10,7 +10,7 @@ Multi-location service order flow, preparation assignments, policy controls and | Area | Included behavior | |---|---| -| Main records | `restaurant/service_orders` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `restaurant/service_orders` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `received`, `preparing`, `ready`, `served`, `void` | @@ -43,6 +43,8 @@ Use `POST /api/restaurant-network/v1/restaurant/service_orders/_batch` for up to Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`restaurant.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/RestaurantNetwork/config/40-resources.json b/app/RestaurantNetwork/config/40-resources.json index c0b9e8a..5f3d9d4 100644 --- a/app/RestaurantNetwork/config/40-resources.json +++ b/app/RestaurantNetwork/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/RestaurantNetwork/config/50-operations.json b/app/RestaurantNetwork/config/50-operations.json index f1a1597..a66aa79 100644 --- a/app/RestaurantNetwork/config/50-operations.json +++ b/app/RestaurantNetwork/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO restaurant_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO restaurant_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/RestaurantNetwork/graphs/domain-transition.forgegraph.json b/app/RestaurantNetwork/graphs/domain-transition.forgegraph.json index b225832..9ba7173 100644 --- a/app/RestaurantNetwork/graphs/domain-transition.forgegraph.json +++ b/app/RestaurantNetwork/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "received", + "preparing", + "ready", + "served", + "void" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE restaurant_service_orders SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE restaurant_service_orders SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO restaurant_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "restaurant.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "restaurant/service_orders", + "restaurant/audit-events" + ], + "invalidate_operations": [ + "restaurant.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/SubscriptionPlatform/README.md b/app/SubscriptionPlatform/README.md index 55ce960..c6a50ba 100644 --- a/app/SubscriptionPlatform/README.md +++ b/app/SubscriptionPlatform/README.md @@ -10,7 +10,7 @@ Tenant subscription lifecycle, plan policies, usage amounts, idempotent transiti | Area | Included behavior | |---|---| -| Main records | `subscriptions/accounts` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `subscriptions/accounts` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `trial`, `active`, `past_due`, `paused`, `cancelled` | @@ -43,6 +43,8 @@ Use `POST /api/subscription-platform/v1/subscriptions/accounts/_batch` for up to Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`subscriptions.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/SubscriptionPlatform/config/40-resources.json b/app/SubscriptionPlatform/config/40-resources.json index e0e35e2..6fef84a 100644 --- a/app/SubscriptionPlatform/config/40-resources.json +++ b/app/SubscriptionPlatform/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/SubscriptionPlatform/config/50-operations.json b/app/SubscriptionPlatform/config/50-operations.json index 5c86479..962f5e4 100644 --- a/app/SubscriptionPlatform/config/50-operations.json +++ b/app/SubscriptionPlatform/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO subscriptions_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO subscriptions_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/SubscriptionPlatform/graphs/domain-transition.forgegraph.json b/app/SubscriptionPlatform/graphs/domain-transition.forgegraph.json index 4ef52fb..3d38e90 100644 --- a/app/SubscriptionPlatform/graphs/domain-transition.forgegraph.json +++ b/app/SubscriptionPlatform/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "trial", + "active", + "past_due", + "paused", + "cancelled" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE subscriptions_accounts SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE subscriptions_accounts SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO subscriptions_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "subscriptions.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "subscriptions/accounts", + "subscriptions/audit-events" + ], + "invalidate_operations": [ + "subscriptions.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/TournamentEngine/README.md b/app/TournamentEngine/README.md index 5604277..72e6423 100644 --- a/app/TournamentEngine/README.md +++ b/app/TournamentEngine/README.md @@ -10,7 +10,7 @@ Match lifecycle, official assignments, ruleset policies, result audit events and | Area | Included behavior | |---|---| -| Main records | `tournaments/matches` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `tournaments/matches` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `scheduled`, `check_in`, `live`, `final`, `disputed` | @@ -43,6 +43,8 @@ Use `POST /api/tournament-engine/v1/tournaments/matches/_batch` for up to 100 re Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`tournaments.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/TournamentEngine/config/40-resources.json b/app/TournamentEngine/config/40-resources.json index 966fab1..9a09aed 100644 --- a/app/TournamentEngine/config/40-resources.json +++ b/app/TournamentEngine/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/TournamentEngine/config/50-operations.json b/app/TournamentEngine/config/50-operations.json index 9766794..f039b50 100644 --- a/app/TournamentEngine/config/50-operations.json +++ b/app/TournamentEngine/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO tournaments_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO tournaments_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/TournamentEngine/graphs/domain-transition.forgegraph.json b/app/TournamentEngine/graphs/domain-transition.forgegraph.json index ff34780..f46c1f9 100644 --- a/app/TournamentEngine/graphs/domain-transition.forgegraph.json +++ b/app/TournamentEngine/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "scheduled", + "check_in", + "live", + "final", + "disputed" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE tournaments_matches SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE tournaments_matches SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO tournaments_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "tournaments.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "tournaments/matches", + "tournaments/audit-events" + ], + "invalidate_operations": [ + "tournaments.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/app/WarehouseGrid/README.md b/app/WarehouseGrid/README.md index e252444..cc5b860 100644 --- a/app/WarehouseGrid/README.md +++ b/app/WarehouseGrid/README.md @@ -10,7 +10,7 @@ Inventory batch ownership, allocation workflow, audit events, quantity totals an | Area | Included behavior | |---|---| -| Main records | `warehouse/inventory_batches` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `warehouse/inventory_batches` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: `received`, `stored`, `allocated`, `picked`, `depleted` | @@ -43,6 +43,8 @@ Use `POST /api/warehouse-grid/v1/warehouse/inventory_batches/_batch` for up to 1 Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`warehouse.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/app/WarehouseGrid/config/40-resources.json b/app/WarehouseGrid/config/40-resources.json index fd1eb5f..d79cb4a 100644 --- a/app/WarehouseGrid/config/40-resources.json +++ b/app/WarehouseGrid/config/40-resources.json @@ -329,7 +329,7 @@ "max_length": 96 }, "payload": { - "type": "json" + "type": "text" }, "idempotency_key": { "type": "string", diff --git a/app/WarehouseGrid/config/50-operations.json b/app/WarehouseGrid/config/50-operations.json index 3dc808a..fc42f60 100644 --- a/app/WarehouseGrid/config/50-operations.json +++ b/app/WarehouseGrid/config/50-operations.json @@ -110,7 +110,8 @@ "required": [ "record_id", "assigned_to", - "step" + "step", + "due_at" ], "additionalProperties": false, "properties": { @@ -139,14 +140,13 @@ }, "statements": [ { - "sql": "INSERT INTO warehouse_workflows (record_id, step, state, assigned_to, due_at, details, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "sql": "INSERT INTO warehouse_workflows (record_id, step, state, assigned_to, due_at, created_at) VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", "mode": "execute", "params": { "record_id": "$body.record_id", "step": "$body.step", "assigned_to": "$body.assigned_to", - "due_at": "$body.due_at", - "details": "$body.step" + "due_at": "$body.due_at" }, "result_name": "assignment" }, diff --git a/app/WarehouseGrid/graphs/domain-transition.forgegraph.json b/app/WarehouseGrid/graphs/domain-transition.forgegraph.json index 53bd5bd..7a22245 100644 --- a/app/WarehouseGrid/graphs/domain-transition.forgegraph.json +++ b/app/WarehouseGrid/graphs/domain-transition.forgegraph.json @@ -14,7 +14,41 @@ "x": 0, "y": 0, "properties": { - "method": "POST" + "method": "POST", + "input_schema": { + "type": "object", + "required": [ + "record_id", + "next_status", + "actor_id" + ], + "additionalProperties": false, + "properties": { + "record_id": { + "type": "integer", + "minimum": 1 + }, + "next_status": { + "type": "string", + "enum": [ + "received", + "stored", + "allocated", + "picked", + "depleted" + ] + }, + "actor_id": { + "type": "string", + "minLength": 1, + "maxLength": 96 + }, + "reason": { + "type": "string", + "maxLength": 500 + } + } + } } }, { @@ -31,38 +65,66 @@ { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, "properties": { - "sql": "UPDATE warehouse_inventory_batches SET status = :next_status WHERE id = :record_id", + "sql": "UPDATE warehouse_inventory_batches SET status = :next_status, updated_at = CURRENT_TIMESTAMP WHERE id = :record_id AND deleted_at IS NULL", "mode": "execute", "params": { "next_status": "$body.next_status", "record_id": "$body.record_id" }, + "require_rowcount_min": 1, + "require_rowcount_max": 1, "result_name": "transition" } }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": { + "sql": "INSERT INTO warehouse_audit_events (record_id, event_type, actor_id, payload, idempotency_key, created_at) VALUES (:record_id, :event_type, :actor_id, :payload, :idempotency_key, CURRENT_TIMESTAMP)", + "mode": "execute", + "params": { + "record_id": "$body.record_id", + "event_type": "status.transitioned", + "actor_id": "$body.actor_id", + "payload": "$body.next_status", + "idempotency_key": "$header.Idempotency-Key" + }, + "result_name": "audit" + } + }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { "name": "warehouse.transition", "method": "POST", "database": "primary", "idempotency": true, - "summary": "Graph model for the audited transition" + "summary": "Atomic, idempotent state transition with audit insertion", + "invalidate_resources": [ + "warehouse/inventory_batches", + "warehouse/audit-events" + ], + "invalidate_operations": [ + "warehouse.dashboard" + ] } }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": { "status_code": 200 @@ -88,11 +150,18 @@ "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec" }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec" + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", diff --git a/scripts/build-example-bundle.py b/scripts/build-example-bundle.py index 1ccd10e..f6dc4e6 100755 --- a/scripts/build-example-bundle.py +++ b/scripts/build-example-bundle.py @@ -9,6 +9,7 @@ import stat import subprocess import tempfile +import unicodedata import zipfile from datetime import datetime, timezone from pathlib import Path @@ -36,6 +37,89 @@ MAX_FILE_BYTES = 16 * 1024 * 1024 +def _portable_name(name: str) -> str: + """Use names that stay distinct when extracted on supported filesystems.""" + devices = {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"} + devices.update( + f"{prefix}{suffix}" for prefix in ("COM", "LPT") for suffix in "123456789¹²³" + ) + for part in name.split("/"): + if ( + not part + or part in {".", ".."} + or part.endswith((".", " ")) + or any( + char in '<>:"\\|?*' or ord(char) < 32 or ord(char) == 127 + for char in part + ) + or part.split(".", 1)[0].rstrip(" ").upper() in devices + ): + raise RuntimeError(f"bundle source has an unsafe filename: {name!r}") + return unicodedata.normalize("NFC", name).casefold() + + +def _same_location(left: Path, right: Path) -> bool: + # Case and Unicode aliases must be refused even on a case-sensitive build + # host. samefile also covers actual filesystem aliases such as short names. + def key(path: Path) -> str: + return unicodedata.normalize("NFC", str(path)).casefold() + + if key(left) == key(right): + return True + try: + return left.samefile(right) + except FileNotFoundError: + return False + + +def _check_delivery_path(path: Path) -> None: + """Fail closed for local state even when Git/manifest checks ignore it.""" + _portable_name(path.relative_to(ROOT).as_posix()) + parts = tuple(part.casefold() for part in path.relative_to(ROOT).parts) + generated = { + ".git", + "__pycache__", + ".pytest_cache", + ".ruff_cache", + ".mypy_cache", + ".venv", + "venv", + "node_modules", + "build", + "dist", + "htmlcov", + } + name = parts[-1] + if ( + any(part in generated or part.endswith(".egg-info") for part in parts) + or (name.startswith(".env") and name != ".env.example") + or name.endswith( + ( + ".pem", + ".key", + ".p12", + ".pfx", + ".db", + ".sqlite", + ".sqlite3", + ".log", + ".pyc", + ".pyo", + ) + ) + or any(marker in name for marker in (".db-", ".sqlite-", ".sqlite3-")) + ): + raise RuntimeError(f"bundle source contains private or generated state: {path}") + + +def _is_link(metadata: os.stat_result) -> bool: + # Windows junctions are reparse points but are not reported as symlinks. + return stat.S_ISLNK(metadata.st_mode) or bool( + getattr(metadata, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + + def _source_date_epoch() -> int: configured = os.environ.get("SOURCE_DATE_EPOCH") if configured: @@ -55,14 +139,15 @@ def _source_date_epoch() -> int: def _sources() -> list[tuple[Path, str, os.stat_result]]: app_root = ROOT / "app" - if app_root.is_symlink() or not app_root.is_dir(): + if _is_link(app_root.lstat()) or not app_root.is_dir(): raise RuntimeError("app root must be a real directory") paths = [] for count, path in enumerate(app_root.rglob("*"), 1): if count > MAX_FILES * 2: raise RuntimeError("bundle source contains too many directory entries") - if path.is_symlink(): - raise RuntimeError(f"bundle source contains symlinks: {path}") + _check_delivery_path(path) + if _is_link(path.lstat()): + raise RuntimeError(f"bundle source contains symlinks or junctions: {path}") if path.is_file(): paths.append(path) paths.extend( @@ -79,10 +164,19 @@ def _sources() -> list[tuple[Path, str, os.stat_result]]: result: list[tuple[Path, str, os.stat_result]] = [] names = set() + components: dict[str, str] = {} total = 0 for path in paths: + _check_delivery_path(path) + for parent in path.parents: + if parent == ROOT: + break + if _is_link(parent.lstat()): + raise RuntimeError( + f"bundle source parent contains symlinks or junctions: {parent}" + ) metadata = path.lstat() - if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode): + if _is_link(metadata) or not stat.S_ISREG(metadata.st_mode): raise RuntimeError( f"bundle source must be a regular non-symlink file: {path}" ) @@ -92,25 +186,34 @@ def _sources() -> list[tuple[Path, str, os.stat_result]]: if total > MAX_TOTAL_BYTES: raise RuntimeError(f"bundle content exceeds {MAX_TOTAL_BYTES} bytes") name = path.relative_to(ROOT).as_posix() - if ( - name.casefold() in names - or any(char in name for char in "\\:") - or any(ord(char) < 32 or ord(char) == 127 for char in name) - ): + key = _portable_name(name) + if key in names: raise RuntimeError( f"bundle source has an unsafe or colliding filename: {name!r}" ) - names.add(name.casefold()) + parts = name.split("/") + for count in range(1, len(parts) + 1): + prefix = "/".join(parts[:count]) + alias = unicodedata.normalize("NFC", prefix).casefold() + if alias in components and components[alias] != prefix: + raise RuntimeError( + f"bundle source has a colliding path component: {prefix!r}" + ) + components[alias] = prefix + names.add(key) result.append((path, name, metadata)) return sorted(result, key=lambda item: item[1]) def build(output: Path) -> None: output = output.resolve() - if output.is_relative_to((ROOT / "app").resolve()): + app_root = (ROOT / "app").resolve() + if any(_same_location(parent, app_root) for parent in (output, *output.parents)): raise RuntimeError("bundle output must not be inside app sources") - output.parent.mkdir(parents=True, exist_ok=True) sources = _sources() + if any(_same_location(output, path.resolve()) for path, _, _ in sources): + raise RuntimeError("bundle output must not replace a delivery source") + output.parent.mkdir(parents=True, exist_ok=True) timestamp = datetime.fromtimestamp( min(max(_source_date_epoch(), 315_532_800), 4_354_819_198), tz=timezone.utc ).timetuple()[:6] diff --git a/scripts/check_manifest.py b/scripts/check_manifest.py index a2c570f..ed91cf0 100755 --- a/scripts/check_manifest.py +++ b/scripts/check_manifest.py @@ -3,6 +3,8 @@ import argparse import hashlib +import re +import stat import subprocess from pathlib import Path @@ -10,6 +12,13 @@ MANIFEST = ROOT / "MANIFEST.sha256" +def _linked(metadata) -> bool: + return stat.S_ISLNK(metadata.st_mode) or bool( + getattr(metadata, "st_file_attributes", 0) + & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) + ) + + def digest(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: @@ -24,6 +33,7 @@ def _release_path(relative: str) -> bool: not relative or any(part in {"", ".", ".."} for part in parts) or "\\" in relative + or ":" in relative ): raise ValueError(f"Unsafe release path: {relative!r}") if any(ord(char) < 32 or ord(char) == 127 for char in relative): @@ -80,8 +90,19 @@ def release_files() -> set[str]: if not _release_path(relative): continue path = ROOT / relative - if path.is_symlink(): - raise ValueError(f"Release files may not be symbolic links: {relative}") + try: + metadata = path.lstat() + except FileNotFoundError: + continue + if _linked(metadata): + raise ValueError(f"Release files may not be links or junctions: {relative}") + for parent in path.parents: + if parent == ROOT: + break + if _linked(parent.lstat()): + raise ValueError( + f"Release parent may not be a link or junction: {relative}" + ) if path.is_file(): result.add(relative) return result @@ -118,6 +139,12 @@ def main(argv: list[str] | None = None) -> int: d, rel = line.split(" ", 1) except ValueError as exc: raise SystemExit(f"Malformed manifest line {n}") from exc + try: + allowed = _release_path(rel) + except ValueError as exc: + raise SystemExit(f"Unsafe manifest line {n}: {exc}") from exc + if not allowed or not re.fullmatch(r"[0-9a-f]{64}", d): + raise SystemExit(f"Invalid release path or digest on manifest line {n}") if rel in expected: raise SystemExit(f"Duplicate manifest entry: {rel}") expected[rel] = d diff --git a/scripts/generate_example_catalog.py b/scripts/generate_example_catalog.py index a1db17d..7c9b176 100755 --- a/scripts/generate_example_catalog.py +++ b/scripts/generate_example_catalog.py @@ -206,7 +206,7 @@ def _catalog_sql(template: str, **identifiers: str) -> str: "domain": "finance", "entity": "batches", "statuses": ["draft", "review", "approved", "posted", "rejected"], - "focus": "Synthetic financial operations workflow with dual-control permissions, batch totals and idempotent audit transitions.", + "focus": "Synthetic financial operations workflow with scoped operator permissions, batch totals and idempotent audit transitions.", }, { "directory": "EditorPluginRegistry", @@ -419,7 +419,8 @@ def _resources(definition: dict[str, Any]) -> dict[str, Any]: "record_id": _column("integer", nullable=False, index=True), "event_type": _column("string", nullable=False, index=True, max_length=64), "actor_id": _column("string", nullable=False, index=True, max_length=96), - "payload": _column("json"), + # SQL transition stores a status token, not an encoded JSON document. + "payload": _column("text"), "idempotency_key": _column("string", index=True, max_length=128), "created_at": _column("datetime", index=True), }, @@ -547,7 +548,7 @@ def _operations(definition: dict[str, Any]) -> dict[str, Any]: "idempotency": True, "input_schema": { "type": "object", - "required": ["record_id", "assigned_to", "step"], + "required": ["record_id", "assigned_to", "step", "due_at"], "additionalProperties": False, "properties": { "record_id": {"type": "integer", "minimum": 1}, @@ -559,8 +560,8 @@ def _operations(definition: dict[str, Any]) -> dict[str, Any]: "statements": [ { "sql": _catalog_sql( - "INSERT INTO {workflow_table} (record_id, step, state, assigned_to, due_at, details, created_at) " - "VALUES (:record_id, :step, 'open', :assigned_to, :due_at, :details, CURRENT_TIMESTAMP)", + "INSERT INTO {workflow_table} (record_id, step, state, assigned_to, due_at, created_at) " + "VALUES (:record_id, :step, 'open', :assigned_to, :due_at, CURRENT_TIMESTAMP)", workflow_table=workflow_table, ), "mode": "execute", @@ -569,7 +570,6 @@ def _operations(definition: dict[str, Any]) -> dict[str, Any]: "step": "$body.step", "assigned_to": "$body.assigned_to", "due_at": "$body.due_at", - "details": "$body.step", }, "result_name": "assignment", }, @@ -596,8 +596,8 @@ def _operations(definition: dict[str, Any]) -> dict[str, Any]: def _graph(definition: dict[str, Any]) -> dict[str, Any]: - domain = definition["domain"] - resource_table = f"{domain}_{definition['entity']}" + # The graph must preserve the same validation, audit and cache contract as RPC. + transition = _operations(definition)["operations"][1] nodes = [ { "id": "request", @@ -605,7 +605,10 @@ def _graph(definition: dict[str, Any]) -> dict[str, Any]: "title": "Transition request", "x": 0, "y": 0, - "properties": {"method": "POST"}, + "properties": { + "method": transition["method"], + "input_schema": transition["input_schema"], + }, }, { "id": "policy", @@ -614,48 +617,50 @@ def _graph(definition: dict[str, Any]) -> dict[str, Any]: "x": 300, "y": 0, "properties": { - "permission": f"{domain}.{definition['entity']}.transition", + "permission": transition["permission"], "public": False, }, }, { "id": "update", "type": "data.mutate", - "title": "Update state", + "title": "Update active record", "x": 600, "y": 0, - "properties": { - "sql": _catalog_sql( - "UPDATE {resource_table} SET status = :next_status WHERE id = :record_id", - resource_table=resource_table, - ), - "mode": "execute", - "params": { - "next_status": "$body.next_status", - "record_id": "$body.record_id", - }, - "result_name": "transition", - }, + "properties": transition["statements"][0], + }, + { + "id": "audit", + "type": "data.mutate", + "title": "Record audit event", + "x": 900, + "y": 0, + "properties": transition["statements"][1], }, { "id": "operation", "type": "operation.call", "title": "Forge operation", - "x": 900, + "x": 1200, "y": 0, "properties": { - "name": f"{domain}.transition", - "method": "POST", - "database": "primary", - "idempotency": True, - "summary": "Graph model for the audited transition", + key: transition[key] + for key in ( + "name", + "method", + "database", + "idempotency", + "summary", + "invalidate_resources", + "invalidate_operations", + ) }, }, { "id": "response", "type": "response.output", "title": "Operation result", - "x": 1200, + "x": 1500, "y": 0, "properties": {"status_code": 200}, }, @@ -679,11 +684,18 @@ def _graph(definition: dict[str, Any]) -> dict[str, Any]: "id": "edge-3", "from_node": "update", "from_port": "exec", - "to_node": "operation", + "to_node": "audit", "to_port": "exec", }, { "id": "edge-4", + "from_node": "audit", + "from_port": "exec", + "to_node": "operation", + "to_port": "exec", + }, + { + "id": "edge-5", "from_node": "operation", "from_port": "exec", "to_node": "response", @@ -829,7 +841,7 @@ def _documents(definition: dict[str, Any]) -> dict[str, str]: | Area | Included behavior | |---|---| -| Main records | `{domain}/{entity}` CRUD with bounded inputs, soft deletion and ownership policy | +| Main records | `{domain}/{entity}` CRUD with bounded inputs, soft deletion and assignment metadata | | Atomic imports | Up to 100 primary records per transaction, with per-row defaults | | Supporting records | Policy, assignment and audit resources | | Workflow | Three transactional/analytics RPCs; states: {", ".join(f"`{status}`" for status in definition["statuses"])} | @@ -862,6 +874,8 @@ def _documents(definition: dict[str, Any]) -> dict[str, str]: Read `app.json` for the API prefix, `config/40-resources.json` for allowed CRUD fields/actions, `config/50-operations.json` for SQL/RPC contracts, and `config/60-events.json` for channel permissions. Keep changes to JSON configuration validated with the main runtime. +`{domain}.assign` requires `record_id`, `assigned_to`, `step` and `due_at`; send `due_at: null` when no deadline is needed. Assignment details remain nullable JSON metadata. Transition audit `payload` stores the new status as text. Role permissions apply across this shared back-office project; the `owner_id` field is assignment metadata and does not enforce per-owner isolation. + This README and the numbered fragments are generated by `scripts/generate_example_catalog.py` in exampleApps. Change that generator when contributing catalog updates, then run it and its `--check` mode. A private deployment can adapt its own copied configuration under the project license. ## Deployment boundaries and license diff --git a/scripts/install-example.ps1 b/scripts/install-example.ps1 index 4d15dd2..9110402 100644 --- a/scripts/install-example.ps1 +++ b/scripts/install-example.ps1 @@ -27,7 +27,7 @@ $Target = Join-Path $Destination $Name Assert-NoReparseParents $Source Assert-NoReparseParents $Target -if (-not (Test-Path (Join-Path $Source "app.json") -PathType Leaf)) { +if (-not (Test-Path -LiteralPath (Join-Path $Source "app.json") -PathType Leaf)) { throw "Unknown example: $Name" } $Links = @(Get-ChildItem -LiteralPath $Source -Recurse -Force | Where-Object { @@ -36,7 +36,7 @@ $Links = @(Get-ChildItem -LiteralPath $Source -Recurse -Force | Where-Object { if ($Links) { throw "Refusing to copy an example that contains symbolic links: $Name" } -if (Test-Path $Target) { +if (Test-Path -LiteralPath $Target) { throw "Refusing to overwrite existing target: $Target" } diff --git a/scripts/install-example.sh b/scripts/install-example.sh index 561115a..d7636ec 100755 --- a/scripts/install-example.sh +++ b/scripts/install-example.sh @@ -14,6 +14,13 @@ if [[ ! "$name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$ ]]; then exit 2 fi destination_root=${2:-"$repository_root/app"} +# Git Bash accepts drive paths; dirname otherwise reaches a fixed point at C:. +if [[ "$destination_root" =~ ^[A-Za-z]:[/\\] ]] && command -v cygpath >/dev/null 2>&1; then + destination_root=$(cygpath -u -- "$destination_root") +fi +while [[ "$destination_root" != / && "$destination_root" == */ ]]; do + destination_root=${destination_root%/} +done source_dir="$repository_root/app/$name" target_dir="$destination_root/$name" @@ -36,7 +43,11 @@ while [[ "$ancestor" != / && "$ancestor" != . ]]; do echo "Refusing a symbolic-link destination parent: $ancestor" >&2 exit 1 fi - ancestor=$(dirname -- "$ancestor") + parent=$(dirname -- "$ancestor") + if [[ "$parent" == "$ancestor" ]]; then + break + fi + ancestor=$parent done mkdir -p -- "$destination_root" staging=$(mktemp -d "$destination_root/.forge-example.XXXXXXXX") @@ -46,6 +57,18 @@ if [[ -e "$target_dir" || -L "$target_dir" ]]; then echo "Target appeared during staging: $target_dir" >&2 exit 1 fi -mv -- "$staging/$name" "$target_dir" +# Move into the known parent. A late target must never receive a nested copy. +if ! mv -n -- "$staging/$name" "$destination_root/"; then + if [[ -e "$target_dir" || -L "$target_dir" ]]; then + echo "Target appeared during installation: $target_dir" >&2 + else + echo "Could not safely install $name at $target_dir" >&2 + fi + exit 1 +fi +if [[ -e "$staging/$name" ]]; then + echo "Target appeared during installation: $target_dir" >&2 + exit 1 +fi echo "Installed $name at $target_dir" echo "Next: run 'forge init', 'forge validate', and 'forge dev' in the destination checkout." diff --git a/scripts/smoke-example-apps.py b/scripts/smoke-example-apps.py index 2c9c45c..d569bcb 100755 --- a/scripts/smoke-example-apps.py +++ b/scripts/smoke-example-apps.py @@ -158,6 +158,66 @@ def _exercise_generated_app( assert transitioned.status_code == 200, ( f"{slug} transition: {transitioned.status_code} {transitioned.text}" ) + audit_rows = client.get( + f"{prefix}/{domain}/audit-events", params={"record_id": item_id}, headers=auth + ) + assert audit_rows.status_code == 200, audit_rows.text + assert audit_rows.json()["items"][0]["payload"] == statuses[1] + + assignment_body = { + "record_id": item_id, + "assigned_to": "ci-reviewer", + "step": "review", + "due_at": None, + } + assigned = client.post( + f"{prefix}/rpc/{domain}.assign", + headers={**auth, "Idempotency-Key": f"assign-{slug}-{nonce}"}, + json=assignment_body, + ) + assert assigned.status_code == 200, assigned.text + workflows = client.get( + f"{prefix}/{domain}/workflows", params={"record_id": item_id}, headers=auth + ) + assert workflows.status_code == 200, workflows.text + assert workflows.json()["items"][0]["step"] == "review" + assert workflows.json()["items"][0]["details"] is None + missing_deadline = client.post( + f"{prefix}/rpc/{domain}.assign", + headers={**auth, "Idempotency-Key": f"missing-deadline-{slug}-{nonce}"}, + json={key: value for key, value in assignment_body.items() if key != "due_at"}, + ) + assert missing_deadline.status_code == 422 and "due_at" in missing_deadline.text + orphan_id = item_id + 10_000_000 + orphan = client.post( + f"{prefix}/rpc/{domain}.assign", + headers={**auth, "Idempotency-Key": f"orphan-{slug}-{nonce}"}, + json={**assignment_body, "record_id": orphan_id}, + ) + assert orphan.status_code == 409, orphan.text + assert not client.get( + f"{prefix}/{domain}/workflows", params={"record_id": orphan_id}, headers=auth + ).json()["items"], "Failed assignment left a workflow row behind" + changed_owner = client.get(f"{route}/{item_id}", headers=auth) + assert changed_owner.json()["owner_id"] == "ci-reviewer" + removed = client.delete(f"{route}/{item_id}", headers=auth) + assert removed.status_code == 200, removed.text + removed_transition = client.post( + f"{prefix}/rpc/{domain}.transition", + headers={**auth, "Idempotency-Key": f"removed-{slug}-{nonce}"}, + json=transition_body, + ) + assert removed_transition.status_code == 409, removed_transition.text + assert ( + len( + client.get( + f"{prefix}/{domain}/audit-events", + params={"record_id": item_id}, + headers=auth, + ).json()["items"] + ) + == 1 + ), "Rejected transition left an audit event behind" if slug == "commerce-core": replay = client.post( f"{prefix}/rpc/{domain}.transition", diff --git a/scripts/test_installers.py b/scripts/test_installers.py new file mode 100644 index 0000000..4be4093 --- /dev/null +++ b/scripts/test_installers.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Exercise public copy installers on real paths and filesystem boundaries.""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +SOURCE = Path(__file__).resolve().parent + + +def bash_path() -> str | None: + if os.name == "nt": + for path in ( + Path(os.environ.get("ProgramFiles", "C:/Program Files")) + / "Git/bin/bash.exe", + Path("C:/msys64/usr/bin/bash.exe"), + ): + if path.is_file(): + return str(path) + return None + return shutil.which("bash") + + +class CopyInstallerTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="forge-install-test-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.checkout = self.root / "checkout" + self.scripts = self.checkout / "scripts" + self.scripts.mkdir(parents=True) + self.source = self.checkout / "app/Test" + self.source.mkdir(parents=True) + (self.source / "app.json").write_text('{"slug":"test"}\n', encoding="utf-8") + (self.source / ".hidden").write_bytes(b"hidden\x00content") + (self.source / "nested").mkdir() + (self.source / "nested/notes.txt").write_text( + "Türkçe 日本語\n", encoding="utf-8" + ) + for name in ("install-example.sh", "install-example.ps1"): + shutil.copyfile(SOURCE / name, self.scripts / name) + + def run_installer(self, shell: str, destination: Path | str, *, env=None): + if shell == "bash": + executable = bash_path() + if not executable: + self.skipTest("Bash is unavailable") + command = [ + executable, + str(self.scripts / "install-example.sh"), + "Test", + str(destination), + ] + if env and env.get("FORGE_TEST_BIN"): + command = [ + executable, + "-c", + 'prefix="$FORGE_TEST_BIN"; ' + 'if command -v cygpath >/dev/null 2>&1; then prefix=$(cygpath -u "$prefix"); fi; ' + 'export PATH="$prefix:$PATH"; exec bash "$@"', + "test-installer", + *command[1:], + ] + else: + executable = shutil.which("pwsh") + if not executable: + self.skipTest("PowerShell is unavailable") + command = [ + executable, + "-NoProfile", + "-File", + str(self.scripts / "install-example.ps1"), + "-Name", + "Test", + "-Destination", + str(destination), + ] + return subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + timeout=20, + env=env, + ) + + def assert_copied(self, destination: Path): + expected = { + path.relative_to(self.source): hashlib.sha256(path.read_bytes()).digest() + for path in self.source.rglob("*") + if path.is_file() + } + actual = { + path.relative_to(destination / "Test"): hashlib.sha256( + path.read_bytes() + ).digest() + for path in (destination / "Test").rglob("*") + if path.is_file() + } + self.assertEqual(actual, expected) + self.assertFalse(list(destination.glob(".forge-example*"))) + + def test_bash_drive_path_spaces_and_existing_target(self): + destination = self.root / "server with spaces/app" + result = self.run_installer("bash", destination) + self.assertEqual(result.returncode, 0, result.stderr) + self.assert_copied(destination) + repeated = self.run_installer("bash", destination) + self.assertNotEqual(repeated.returncode, 0) + self.assertIn("Refusing to overwrite", repeated.stderr) + self.assert_copied(destination) + + def test_powershell_literal_brackets_and_existing_target(self): + destination = self.root / "server [private]/app" + result = self.run_installer("pwsh", destination) + self.assertEqual(result.returncode, 0, result.stderr) + self.assert_copied(destination) + repeated = self.run_installer("pwsh", destination) + self.assertNotEqual(repeated.returncode, 0) + self.assertIn("Refusing to overwrite", repeated.stderr) + self.assert_copied(destination) + + def test_bash_trailing_slash_link_parent_is_rejected(self): + outside = self.root / "outside" + outside.mkdir() + link = self.root / "link" + if os.name == "nt": + result = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(link), str(outside)], + capture_output=True, + ) + if result.returncode: + self.skipTest("Junction creation unavailable") + else: + link.symlink_to(outside, target_is_directory=True) + result = self.run_installer("bash", str(link).replace("\\", "/") + "/") + self.assertNotEqual(result.returncode, 0) + self.assertIn("symbolic-link destination parent", result.stderr) + self.assertFalse((outside / "Test").exists()) + + def test_bash_late_target_is_preserved_without_nested_copy(self): + wrapper = self.root / "wrapper" + wrapper.mkdir() + fake_move = wrapper / "mv" + fake_move.write_text( + "#!/usr/bin/env bash\n" + "destination=${@: -1}\n" + 'mkdir -p -- "${destination%/}/Test"\n' + 'printf user-data > "${destination%/}/Test/user.txt"\n' + # Modern GNU mv reports a no-clobber skip as failure; reproduce + # that behavior on Git Bash too, rather than relying on its version. + '/usr/bin/mv "$@" || true\n' + "exit 1\n", + encoding="utf-8", + newline="\n", + ) + fake_move.chmod(0o755) + destination = self.root / "late-target" + environment = {**os.environ, "FORGE_TEST_BIN": str(wrapper)} + result = self.run_installer("bash", destination, env=environment) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Target appeared", result.stderr) + self.assertEqual((destination / "Test/user.txt").read_bytes(), b"user-data") + self.assertFalse((destination / "Test/Test").exists()) + self.assertFalse((destination / "Test/app.json").exists()) + self.assertFalse(list(destination.glob(".forge-example*"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_manifest.py b/scripts/test_manifest.py new file mode 100644 index 0000000..d9a67b3 --- /dev/null +++ b/scripts/test_manifest.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Reject untrusted manifest paths and filesystem link boundaries before hashing.""" + +import importlib.util +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +SPEC = importlib.util.spec_from_file_location( + "manifest", Path(__file__).with_name("check_manifest.py") +) +manifest = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(manifest) + + +class ManifestBoundaryTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="forge-manifest-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.path = self.root / "MANIFEST.sha256" + context = patch.multiple(manifest, ROOT=self.root, MANIFEST=self.path) + context.start() + self.addCleanup(context.stop) + + def test_invalid_records_do_not_hash_external_paths(self): + for relative, digest in ( + ("../outside.txt", "a" * 64), + ("/absolute/file", "a" * 64), + ("scripts\\tool.py", "a" * 64), + ("C:/outside/private.txt", "a" * 64), + ("README.md:alternate-stream", "a" * 64), + (".env", "a" * 64), + ("README.md", "not-a-sha256"), + ): + with self.subTest(path=relative): + self.path.write_text(f"{digest} {relative}\n", encoding="utf-8") + with ( + patch.object(manifest, "digest") as digest_file, + self.assertRaises(SystemExit), + ): + manifest.main([]) + digest_file.assert_not_called() + + @unittest.skipUnless(os.name == "nt", "Windows junction boundary") + def test_junction_parent_never_hashes_external_content(self): + outside = self.root / "outside" + outside.mkdir() + (outside / "private.txt").write_bytes(b"outside fixture") + link = self.root / "app" + created = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(link), str(outside)], capture_output=True + ) + if created.returncode: + self.skipTest("Junction creation unavailable") + try: + with ( + patch.object(manifest.subprocess, "check_output", side_effect=OSError), + self.assertRaisesRegex(ValueError, "junction"), + ): + manifest.release_files() + finally: + link.rmdir() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_release_tools.py b/scripts/test_release_tools.py index 6de98ee..14bb228 100755 --- a/scripts/test_release_tools.py +++ b/scripts/test_release_tools.py @@ -4,6 +4,7 @@ import hashlib import importlib.util import os +import subprocess import tempfile import unittest import zipfile @@ -105,6 +106,141 @@ def test_timestamp_and_output_boundaries(self): with zipfile.ZipFile(self.root / "epoch.zip") as archive: self.assertEqual(archive.infolist()[0].date_time[0], year) + def test_bundle_output_must_not_replace_delivery_sources(self): + for relative in ( + "README.md", + "readme.md", + "LICENSE", + "scripts/install-example.sh", + "SCRIPTS/INSTALL-EXAMPLE.SH", + ): + with self.subTest(output=relative): + output = self.root / relative + originals = { + source: source.read_bytes() + for source in ( + self.root / "README.md", + self.root / "LICENSE", + self.root / "scripts/install-example.sh", + ) + } + with self.assertRaisesRegex(RuntimeError, "replace.*source"): + bundle.build(output) + for source, original in originals.items(): + self.assertEqual(source.read_bytes(), original) + + def test_output_app_case_alias_is_refused_on_every_build_host(self): + output = self.root / "APP/output.zip" + with self.assertRaisesRegex(RuntimeError, "inside app"): + bundle.build(output) + self.assertFalse(output.exists()) + + def test_windows_extraction_aliases_cannot_overwrite_bundle_members(self): + # NTFS extended paths let this reproduce a POSIX-created archive on + # Windows too. Windows ZIP extraction strips the trailing dot. + root = self.root + if os.name == "nt": + root = Path("\\\\?\\" + str(root)) + first = root / "app/Test/collision.txt" + alias = root / "app/Test/collision.txt." + first.write_bytes(b"keep the first member") + alias.write_bytes(b"would overwrite the first member") + output = self.root / "existing.zip" + output.write_bytes(b"previous verified archive") + try: + with ( + patch.object(bundle, "ROOT", root), + self.assertRaisesRegex(RuntimeError, "unsafe.*filename"), + ): + bundle.build(output) + self.assertEqual(output.read_bytes(), b"previous verified archive") + self.assertFalse(list(self.root.glob("*.tmp"))) + finally: + alias.unlink() + + def test_nonportable_source_names_are_refused_before_replacing_output(self): + root = self.root + if os.name == "nt": + root = Path("\\\\?\\" + str(root)) + names = ["trailing.txt ", "CON.txt", "AUX.config.json", "COM¹.txt"] + if os.name != "nt": + names.extend(["bad?name.json", 'bad"name.json', "bad|name.json"]) + output = self.root / "existing.zip" + output.write_bytes(b"previous verified archive") + for name in names: + with self.subTest(source=name): + source = root / "app/Test" / name + source.write_bytes(b"nonportable source fixture") + try: + with ( + patch.object(bundle, "ROOT", root), + self.assertRaisesRegex(RuntimeError, "unsafe.*filename"), + ): + bundle.build(output) + self.assertEqual(output.read_bytes(), b"previous verified archive") + self.assertFalse(list(self.root.glob("*.tmp"))) + finally: + source.unlink() + + def test_unicode_normalization_collisions_are_refused(self): + first = self.root / "app/Test/caf\u00e9.json" + alias = self.root / "app/Test/cafe\u0301.json" + first.write_bytes(b"first source") + alias.write_bytes(b"second source") + if first.samefile(alias): + self.skipTest("Filesystem already normalizes Unicode names") + output = self.root / "existing.zip" + output.write_bytes(b"previous verified archive") + with self.assertRaisesRegex(RuntimeError, "colliding"): + bundle.build(output) + self.assertEqual(output.read_bytes(), b"previous verified archive") + self.assertFalse(list(self.root.glob("*.tmp"))) + + def test_directory_aliases_cannot_merge_on_extraction(self): + first = self.root / "app/Test/caf\u00e9" + alias = self.root / "app/Test/cafe\u0301" + first.mkdir() + alias.mkdir(exist_ok=True) + if first.samefile(alias): + self.skipTest("Filesystem already normalizes Unicode directory names") + (first / "first.json").write_bytes(b"first directory") + (alias / "second.json").write_bytes(b"would merge into the first directory") + output = self.root / "existing.zip" + output.write_bytes(b"previous verified archive") + with self.assertRaisesRegex(RuntimeError, "colliding"): + bundle.build(output) + self.assertEqual(output.read_bytes(), b"previous verified archive") + self.assertFalse(list(self.root.glob("*.tmp"))) + + @unittest.skipUnless(os.name == "nt", "Windows junction boundary") + def test_windows_junction_sources_are_rejected(self): + outside = self.root / "outside" + outside.mkdir() + (outside / "private-key.txt").write_bytes(b"outside fixture never bundled") + for relative in ("app/Test/external", "scripts", "app"): + with self.subTest(source=relative): + link = self.root / relative + original = link.with_name(link.name + ".original") + if link.exists(): + link.rename(original) + result = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(link), str(outside)], + capture_output=True, + ) + if result.returncode: + if original.exists(): + original.rename(link) + self.skipTest("Junction creation unavailable") + try: + output = self.root / "junction.zip" + with self.assertRaises(RuntimeError): + bundle.build(output) + self.assertFalse(output.exists()) + finally: + link.rmdir() + if original.exists(): + original.rename(link) + def test_changed_source_preserves_existing_output(self): output = self.root / "existing.zip" output.write_bytes(b"previous verified archive") @@ -120,6 +256,58 @@ def test_changed_source_preserves_existing_output(self): self.assertEqual(output.read_bytes(), b"previous verified archive") self.assertFalse(list(self.root.glob("*.tmp"))) + def test_private_runtime_sources_never_enter_delivery_archives(self): + output = self.root / "existing.zip" + for relative in ( + ".env", + ".env.production", + "config/signing.key", + "config/client.p12", + "data/customer.db", + "data/customer.db-wal", + "data/customer.sqlite3", + "logs/server.log", + "__pycache__/worker.cpython-313.pyc", + ".git/config", + "node_modules/package/index.js", + ): + with self.subTest(source=relative): + source = self.root / "app/Test" / relative + source.parent.mkdir(parents=True, exist_ok=True) + source.write_bytes(b"synthetic private runtime content") + output.write_bytes(b"previous verified archive") + try: + with self.assertRaisesRegex(RuntimeError, "private or generated"): + bundle.build(output) + self.assertEqual(output.read_bytes(), b"previous verified archive") + self.assertFalse(list(self.root.glob("*.tmp"))) + finally: + source.unlink() + parent = source.parent + while parent != self.root / "app/Test": + try: + parent.rmdir() + except OSError: + break + parent = parent.parent + + def test_project_data_and_environment_templates_remain_copy_ready(self): + for relative in (".env.example", "data/seed.json", "config/public.pem.example"): + source = self.root / "app/Test" / relative + source.parent.mkdir(parents=True, exist_ok=True) + source.write_bytes(b"public fixture") + output = self.root / "templates.zip" + bundle.build(output) + with zipfile.ZipFile(output) as archive: + for relative in ( + ".env.example", + "data/seed.json", + "config/public.pem.example", + ): + self.assertEqual( + archive.read(f"app/Test/{relative}"), b"public fixture" + ) + if __name__ == "__main__": unittest.main()