@reventlessdev/reventless-spec 3.0.0-alpha.154 → 3.0.0-alpha.155

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.155 (2026-10-01)
7
+
8
+ ### Features
9
+
10
+ * **spec, aws, core, local:** an app declares its environments, and every deployment records its commit ([c7cc925](https://github.com/ReventlessDev/reventless-core/commit/c7cc92573e97742b681a9000cea0bdfd6e9a33fc))
11
+ * **spec, core, gwt, ppx:** a command acts only on what its caller owns ([2636f0e](https://github.com/ReventlessDev/reventless-core/commit/2636f0ee44797ee2c0b0c40a7107cf306113173f))
12
+
13
+
6
14
  # 3.0.0-alpha.154 (2026-09-29)
7
15
 
8
16
  **Note:** Version bump only for package @reventlessdev/reventless-spec
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.154",
3
+ "version": "3.0.0-alpha.155",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -138,3 +138,33 @@ let variantFieldNames = (schema: S.t<unknown>, ~variant: string): array<string>
138
138
  | _ => []
139
139
  }
140
140
  }
141
+
142
+ /**
143
+ The owner fields of every constructor that declares one, keyed by its tag.
144
+
145
+ Read off a slice's consumed events, this says which recorded facts name the
146
+ owner of the partition a command acts on. An empty dict means the union records
147
+ no owner, and the handler then has nothing to enforce — so, as with every reader
148
+ here, it must be handed the real schema: a permissive stand-in answers empty.
149
+ */
150
+ let fieldNamesByVariant = (schema: S.t<unknown>): dict<array<string>> => {
151
+ let byVariant = Dict.make()
152
+ let visit = (properties: dict<S.t<unknown>>) =>
153
+ switch (properties->Dict.get("TAG"), fieldNamesOfProperties(properties)) {
154
+ | (_, []) => ()
155
+ | (Some(String({const: ?Some(name)})), fields) => byVariant->Dict.set(name, fields)
156
+ | _ => ()
157
+ }
158
+ switch schema {
159
+ | AnyOf({anyOf}) =>
160
+ anyOf->Array.forEach(v =>
161
+ switch v {
162
+ | Object({properties}) => visit(properties)
163
+ | _ => ()
164
+ }
165
+ )
166
+ | Object({properties}) => visit(properties)
167
+ | _ => ()
168
+ }
169
+ byVariant
170
+ }
@@ -90,6 +90,41 @@ function variantFieldNames(schema, variant) {
90
90
  }
91
91
  }
92
92
 
93
+ function fieldNamesByVariant(schema) {
94
+ let byVariant = {};
95
+ let visit = properties => {
96
+ let match = properties["TAG"];
97
+ let match$1 = fieldNamesOfProperties(properties);
98
+ if (match$1.length === 0) {
99
+ return;
100
+ }
101
+ if (match === undefined) {
102
+ return;
103
+ }
104
+ if (match.type !== "string") {
105
+ return;
106
+ }
107
+ let name = match.const;
108
+ if (name !== undefined) {
109
+ byVariant[name] = match$1;
110
+ return;
111
+ }
112
+ };
113
+ switch (schema.type) {
114
+ case "object" :
115
+ visit(schema.properties);
116
+ break;
117
+ case "anyOf" :
118
+ schema.anyOf.forEach(v => {
119
+ if (v.type === "object") {
120
+ return visit(v.properties);
121
+ }
122
+ });
123
+ break;
124
+ }
125
+ return byVariant;
126
+ }
127
+
93
128
  export {
94
129
  ownerId,
95
130
  mark,
@@ -100,5 +135,6 @@ export {
100
135
  fieldNamesOfProperties,
101
136
  fieldNames,
102
137
  variantFieldNames,
138
+ fieldNamesByVariant,
103
139
  }
104
140
  /* ownerId Not a pure module */
@@ -687,6 +687,9 @@ let observe = (
687
687
 
688
688
  scenarios->Array.filterMap(s =>
689
689
  switch s.whenElements->Array.get(0) {
690
+ // `thenRefused`: the caller did not own the row, which says nothing about
691
+ // the states the command is legal in.
692
+ | Some(_) if s.thenKind == "forbidden" => None
690
693
  | Some(command) if s.whenKind == "command" =>
691
694
  let idField = idFieldFor(command.name)
692
695
  let idValue = idField->Option.flatMap(field => command.values->valueOf(~field))
@@ -503,6 +503,9 @@ function observe(scenarios, map, idFieldFor) {
503
503
  if (command === undefined) {
504
504
  return;
505
505
  }
506
+ if (s.thenKind === "forbidden") {
507
+ return;
508
+ }
506
509
  if (s.whenKind !== "command") {
507
510
  return;
508
511
  }
@@ -0,0 +1,84 @@
1
+ /***
2
+ Where a deployment came from: the commit it was built from, whether the tree had
3
+ uncommitted changes, and the environment it was made for.
4
+
5
+ Read by the deploy while it runs, and recorded with the deployment, so a reader
6
+ can say which commit runs in which environment without asking whoever deployed.
7
+ A deployment from CI knows its commit from the CI's variables; one from a laptop
8
+ asks git. Neither gives up a timestamp in place of a commit: a timestamp is not
9
+ an answer to *which commit*, and a reader cannot tell it from one.
10
+ */
11
+
12
+ type t = {
13
+ commit: option<string>,
14
+ /** True only when the commit came from git and the working tree had changes:
15
+ what was deployed is not what the commit says. */
16
+ dirty: bool,
17
+ environment: option<string>,
18
+ purpose: option<string>,
19
+ tag: option<string>,
20
+ pullRequest: option<int>,
21
+ }
22
+
23
+ /** What git can say about the checkout the deploy runs in. */
24
+ type git = {
25
+ head: unit => option<string>,
26
+ dirty: unit => bool,
27
+ }
28
+
29
+ let nonEmpty = (value: option<string>): option<string> =>
30
+ value->Option.flatMap(v => v->String.trim == "" ? None : Some(v->String.trim))
31
+
32
+ /**
33
+ The provenance of a deployment from the process's variables, asking git only when
34
+ no CI variable names the commit.
35
+
36
+ `REVENTLESS_COMMIT` comes first because on a pull request `GITHUB_SHA` is the merge
37
+ commit GitHub made, not the head the review environment runs.
38
+ */
39
+ let fromEnv = (env: dict<string>, ~git: git): t => {
40
+ let get = name => env->Dict.get(name)->nonEmpty
41
+ let fromCi =
42
+ get("REVENTLESS_COMMIT")
43
+ ->Option.orElse(get("GITHUB_SHA"))
44
+ ->Option.orElse(get("CI_COMMIT_SHA"))
45
+ let (commit, dirty) = switch fromCi {
46
+ | Some(_) => (fromCi, false)
47
+ | None =>
48
+ switch git.head() {
49
+ | Some(_) as head => (head, git.dirty())
50
+ | None => (None, false)
51
+ }
52
+ }
53
+ {
54
+ commit,
55
+ dirty,
56
+ environment: get("REVENTLESS_ENVIRONMENT"),
57
+ purpose: get("REVENTLESS_ENVIRONMENT_PURPOSE"),
58
+ tag: get("GITHUB_REF_TYPE") == Some("tag") ? get("GITHUB_REF_NAME") : None,
59
+ pullRequest: get("REVENTLESS_PULL_REQUEST")->Option.flatMap(n => Int.fromString(n)),
60
+ }
61
+ }
62
+
63
+ let run = (~cwd: string, args: array<string>): option<string> =>
64
+ try {
65
+ Some(
66
+ NodeChildProcess.execFileSync(
67
+ "git",
68
+ args,
69
+ {cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"]},
70
+ )->String.trim,
71
+ )
72
+ } catch {
73
+ | _ => None
74
+ }
75
+
76
+ /** Git in `cwd`. A directory that is not a checkout, or a machine with no git,
77
+ answers *no commit* and *not dirty*. */
78
+ let gitAt = (~cwd: string): git => {
79
+ head: () => run(~cwd, ["rev-parse", "HEAD"])->nonEmpty,
80
+ dirty: () => run(~cwd, ["status", "--porcelain"])->nonEmpty->Option.isSome,
81
+ }
82
+
83
+ /** The provenance of the deployment this process is making, in its working directory. */
84
+ let current = (): t => fromEnv(NodeProcess.env, ~git=gitAt(~cwd=NodeProcess.cwd()))
@@ -0,0 +1,86 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
6
+ import * as Nodechild_process from "node:child_process";
7
+
8
+ function nonEmpty(value) {
9
+ return Stdlib_Option.flatMap(value, v => {
10
+ if (v.trim() === "") {
11
+ return;
12
+ } else {
13
+ return v.trim();
14
+ }
15
+ });
16
+ }
17
+
18
+ function fromEnv(env, git) {
19
+ let fromCi = Stdlib_Option.orElse(Stdlib_Option.orElse(nonEmpty(env["REVENTLESS_COMMIT"]), nonEmpty(env["GITHUB_SHA"])), nonEmpty(env["CI_COMMIT_SHA"]));
20
+ let match;
21
+ if (fromCi !== undefined) {
22
+ match = [
23
+ fromCi,
24
+ false
25
+ ];
26
+ } else {
27
+ let head = git.head();
28
+ match = head !== undefined ? [
29
+ head,
30
+ git.dirty()
31
+ ] : [
32
+ undefined,
33
+ false
34
+ ];
35
+ }
36
+ return {
37
+ commit: match[0],
38
+ dirty: match[1],
39
+ environment: nonEmpty(env["REVENTLESS_ENVIRONMENT"]),
40
+ purpose: nonEmpty(env["REVENTLESS_ENVIRONMENT_PURPOSE"]),
41
+ tag: Primitive_object.equal(nonEmpty(env["GITHUB_REF_TYPE"]), "tag") ? nonEmpty(env["GITHUB_REF_NAME"]) : undefined,
42
+ pullRequest: Stdlib_Option.flatMap(nonEmpty(env["REVENTLESS_PULL_REQUEST"]), n => Stdlib_Int.fromString(n, undefined))
43
+ };
44
+ }
45
+
46
+ function run(cwd, args) {
47
+ try {
48
+ return Nodechild_process.execFileSync("git", args, {
49
+ cwd: cwd,
50
+ encoding: "utf8",
51
+ stdio: [
52
+ "ignore",
53
+ "pipe",
54
+ "ignore"
55
+ ]
56
+ }).trim();
57
+ } catch (exn) {
58
+ return;
59
+ }
60
+ }
61
+
62
+ function gitAt(cwd) {
63
+ return {
64
+ head: () => nonEmpty(run(cwd, [
65
+ "rev-parse",
66
+ "HEAD"
67
+ ])),
68
+ dirty: () => Stdlib_Option.isSome(nonEmpty(run(cwd, [
69
+ "status",
70
+ "--porcelain"
71
+ ])))
72
+ };
73
+ }
74
+
75
+ function current() {
76
+ return fromEnv(process.env, gitAt(process.cwd()));
77
+ }
78
+
79
+ export {
80
+ nonEmpty,
81
+ fromEnv,
82
+ run,
83
+ gitAt,
84
+ current,
85
+ }
86
+ /* node:child_process Not a pure module */
@@ -0,0 +1,230 @@
1
+ /***
2
+ The environments an app deploys to, as a file: `environments.yaml`, beside its
3
+ `deploy-manifest.yaml`.
4
+
5
+ ```yaml
6
+ environments:
7
+ - { name: review, purpose: review, pullRequestsInto: dev }
8
+ - { name: dev, purpose: dev, branch: dev }
9
+ - { name: test, purpose: test, branch: test }
10
+ - { name: production, purpose: production, branch: main, tags: "v*", stack: prod }
11
+ - { name: demo, purpose: demo, tags: "v*" }
12
+ ```
13
+
14
+ A *shared* environment has one stack and is fed by a branch, by tags, or by both.
15
+ A *review* environment is fed by the pull requests into one branch and has one
16
+ stack per open pull request, named `<name>-pr-<number>`.
17
+
18
+ Here rather than in the AWS package because more than the deploy reads it: a
19
+ tool that shows which commit runs where needs the same answer to *which branch
20
+ feeds which environment* as the workflow that deploys, and a second reading of
21
+ the file is how the two would come to disagree.
22
+
23
+ The file is optional. Without it a deploy uses the branch name as the stack, as
24
+ it always has; nothing here is consulted.
25
+ */
26
+
27
+ /**
28
+ What an environment is for. `Other` keeps a team's own word rather than refusing
29
+ the file, because a purpose nothing here branches on costs nothing to carry.
30
+ */
31
+ type purpose =
32
+ | Dev
33
+ | Test
34
+ | Production
35
+ | Demo
36
+ | Review
37
+ | Other(string)
38
+
39
+ let purposeSchema: S.t<purpose> = S.union([
40
+ S.literal("dev")->S.shape(_ => Dev),
41
+ S.literal("test")->S.shape(_ => Test),
42
+ S.literal("production")->S.shape(_ => Production),
43
+ S.literal("demo")->S.shape(_ => Demo),
44
+ S.literal("review")->S.shape(_ => Review),
45
+ S.string->S.shape(other => Other(other)),
46
+ ])
47
+
48
+ let purposeName = (purpose: purpose): string =>
49
+ switch purpose {
50
+ | Dev => "dev"
51
+ | Test => "test"
52
+ | Production => "production"
53
+ | Demo => "demo"
54
+ | Review => "review"
55
+ | Other(name) => name
56
+ }
57
+
58
+ /** One entry as the file spells it. [parseString] turns it into an [environment]. */
59
+ @schema
60
+ type entry = {
61
+ name: string,
62
+ purpose: @s.matches(purposeSchema) purpose,
63
+ branch?: string,
64
+ tags?: string,
65
+ stack?: string,
66
+ pullRequestsInto?: string,
67
+ drafts?: bool,
68
+ }
69
+
70
+ @schema
71
+ type file = {environments: array<entry>}
72
+
73
+ /** What feeds an environment, and so how many stacks it has. */
74
+ type feed =
75
+ | Shared({branch: option<string>, tags: option<string>, stack: string})
76
+ | ReviewOf({into: string, drafts: bool})
77
+
78
+ type environment = {name: string, purpose: purpose, feed: feed}
79
+
80
+ type t = array<environment>
81
+
82
+ // ── Reading ──────────────────────────────────────────────────────────────────
83
+
84
+ @module("yaml") external parseYaml: string => JSON.t = "parse"
85
+ @module("yaml") external stringifyYaml: JSON.t => string = "stringify"
86
+
87
+ let environmentOf = (entry: entry): result<environment, string> =>
88
+ switch (entry.pullRequestsInto, entry.branch, entry.tags) {
89
+ | (Some(_), Some(_), _) | (Some(_), _, Some(_)) =>
90
+ Error(`${entry.name}: pullRequestsInto cannot be combined with branch or tags`)
91
+ | (Some(_), None, None) if entry.stack->Option.isSome =>
92
+ Error(`${entry.name}: a review environment names its stacks itself; remove stack`)
93
+ | (Some(into), None, None) =>
94
+ Ok({
95
+ name: entry.name,
96
+ purpose: entry.purpose,
97
+ feed: ReviewOf({into, drafts: entry.drafts->Option.getOr(false)}),
98
+ })
99
+ | (None, None, None) =>
100
+ Error(`${entry.name}: says neither branch, tags nor pullRequestsInto, so nothing feeds it`)
101
+ | (None, _, _) if entry.drafts->Option.isSome =>
102
+ Error(`${entry.name}: drafts is only meaningful with pullRequestsInto`)
103
+ | (None, branch, tags) =>
104
+ Ok({
105
+ name: entry.name,
106
+ purpose: entry.purpose,
107
+ feed: Shared({branch, tags, stack: entry.stack->Option.getOr(entry.name)}),
108
+ })
109
+ }
110
+
111
+ /** Parses an environments document. Strict, like the accounts manifest: a
112
+ malformed entry refuses the whole file, because a skipped environment is a
113
+ branch that silently stops deploying. */
114
+ let parseString = (yamlText: string): result<t, string> =>
115
+ switch try Ok(S.parseOrThrow(parseYaml(yamlText), ~to=fileSchema)) catch {
116
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr("YAML parse error"))
117
+ | _ => Error("YAML parse error")
118
+ } {
119
+ | Error(_) as e => e
120
+ | Ok({environments}) =>
121
+ let names = environments->Array.map(e => e.name)
122
+ switch names->Array.find(n => names->Array.filter(m => m == n)->Array.length > 1) {
123
+ | Some(duplicate) => Error(`${duplicate}: named twice`)
124
+ | None =>
125
+ environments->Array.reduce(Ok([]), (acc, entry) =>
126
+ switch (acc, environmentOf(entry)) {
127
+ | (Error(_) as e, _) => e
128
+ | (Ok(_), Error(message)) => Error(message)
129
+ | (Ok(done), Ok(environment)) => Ok(done->Array.concat([environment]))
130
+ }
131
+ )
132
+ }
133
+ }
134
+
135
+ let parseFile = (path: string): result<t, string> =>
136
+ try {
137
+ parseString(NodeFs.readFileSync(path))
138
+ } catch {
139
+ | JsExn(err) => Error(JsExn.message(err)->Option.getOr(`Cannot read ${path}`))
140
+ | _ => Error(`Cannot read ${path}`)
141
+ }
142
+
143
+ let entryOf = (environment: environment): entry =>
144
+ switch environment.feed {
145
+ | Shared({branch, tags, stack}) => {
146
+ name: environment.name,
147
+ purpose: environment.purpose,
148
+ ?branch,
149
+ ?tags,
150
+ stack: ?(stack == environment.name ? None : Some(stack)),
151
+ }
152
+ | ReviewOf({into, drafts}) => {
153
+ name: environment.name,
154
+ purpose: environment.purpose,
155
+ pullRequestsInto: into,
156
+ drafts: ?(drafts ? Some(true) : None),
157
+ }
158
+ }
159
+
160
+ /** The document for a set of environments. Comments are not kept: this writes a
161
+ new file, it does not edit one. */
162
+ let print = (environments: t): string =>
163
+ {environments: environments->Array.map(entryOf)}->Util_Sury.toJson(fileSchema)->stringifyYaml
164
+
165
+ /** Where a deploy looks for it: beside the deploy manifest. */
166
+ let fileName = "environments.yaml"
167
+
168
+ let pathBeside = (~manifest: string): string =>
169
+ NodePath.join([NodePath.dirname(manifest), fileName])
170
+
171
+ // ── Resolving ────────────────────────────────────────────────────────────────
172
+
173
+ /**
174
+ A branch or tag pattern as GitHub's workflow filters spell them: `*` matches
175
+ within one path segment, `**` across segments. Nothing else is special.
176
+ */
177
+ let matches = (~pattern: string, name: string): bool => {
178
+ let escaped =
179
+ pattern
180
+ ->String.split("**")
181
+ ->Array.map(part =>
182
+ part
183
+ ->String.replaceRegExp(/[.+?^${}()|[\]\\]/g, "\\$&")
184
+ ->String.replaceAll("*", "[^/]*")
185
+ )
186
+ ->Array.join(".*")
187
+ RegExp.fromString(`^${escaped}$`)->RegExp.test(name)
188
+ }
189
+
190
+ /** What a push or a tag deploys to. */
191
+ type ref_ =
192
+ | Branch(string)
193
+ | Tag(string)
194
+
195
+ /** `refs/heads/test` and `refs/tags/v1`; anything else is not deployable. */
196
+ let refOf = (ref: string): option<ref_> =>
197
+ if ref->String.startsWith("refs/heads/") {
198
+ Some(Branch(ref->String.slice(~start=11)))
199
+ } else if ref->String.startsWith("refs/tags/") {
200
+ Some(Tag(ref->String.slice(~start=10)))
201
+ } else {
202
+ None
203
+ }
204
+
205
+ type resolved = {name: string, stack: string, purpose: purpose}
206
+
207
+ /** The first shared environment, in file order, that the branch or tag feeds. */
208
+ let resolve = (environments: t, ref: ref_): option<resolved> =>
209
+ environments->Array.findMap(({name, purpose, feed}) =>
210
+ switch (feed, ref) {
211
+ | (Shared({branch: Some(pattern), stack}), Branch(branch)) if matches(~pattern, branch) =>
212
+ Some({name, stack, purpose})
213
+ | (Shared({tags: Some(pattern), stack}), Tag(tag)) if matches(~pattern, tag) =>
214
+ Some({name, stack, purpose})
215
+ | (Shared(_), _) | (ReviewOf(_), _) => None
216
+ }
217
+ )
218
+
219
+ /** The review stack of one pull request, if the branch it targets has a review
220
+ environment. `draft` pull requests get one only where the environment asks. */
221
+ let resolvePullRequest = (environments: t, ~base: string, ~number: int, ~draft: bool): option<
222
+ resolved,
223
+ > =>
224
+ environments->Array.findMap(({name, purpose, feed}) =>
225
+ switch feed {
226
+ | ReviewOf({into, drafts}) if matches(~pattern=into, base) && (drafts || !draft) =>
227
+ Some({name, stack: `${name}-pr-${number->Int.toString}`, purpose})
228
+ | ReviewOf(_) | Shared(_) => None
229
+ }
230
+ )
@@ -0,0 +1,314 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Sury from "sury";
4
+ import * as Yaml from "yaml";
5
+ import * as Nodefs from "node:fs";
6
+ import * as Nodepath from "node:path";
7
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
8
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
9
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
10
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
11
+ import * as Util_Sury$Reventless from "../util/Util_Sury.res.mjs";
12
+
13
+ let purposeSchema = Sury.union([
14
+ Sury.shape(Sury.literal("dev"), param => "Dev"),
15
+ Sury.shape(Sury.literal("test"), param => "Test"),
16
+ Sury.shape(Sury.literal("production"), param => "Production"),
17
+ Sury.shape(Sury.literal("demo"), param => "Demo"),
18
+ Sury.shape(Sury.literal("review"), param => "Review"),
19
+ Sury.shape(Sury.string, other => ({
20
+ TAG: "Other",
21
+ _0: other
22
+ }))
23
+ ]);
24
+
25
+ function purposeName(purpose) {
26
+ if (typeof purpose === "object") {
27
+ return purpose._0;
28
+ }
29
+ switch (purpose) {
30
+ case "Dev" :
31
+ return "dev";
32
+ case "Test" :
33
+ return "test";
34
+ case "Production" :
35
+ return "production";
36
+ case "Demo" :
37
+ return "demo";
38
+ case "Review" :
39
+ return "review";
40
+ }
41
+ }
42
+
43
+ let entrySchema = Sury.$schema(s => ({
44
+ name: s.m(Sury.string),
45
+ purpose: s.m(purposeSchema),
46
+ branch: s.m(Sury.$option(Sury.string)),
47
+ tags: s.m(Sury.$option(Sury.string)),
48
+ stack: s.m(Sury.$option(Sury.string)),
49
+ pullRequestsInto: s.m(Sury.$option(Sury.string)),
50
+ drafts: s.m(Sury.$option(Sury.boolean))
51
+ }));
52
+
53
+ let fileSchema = Sury.$schema(s => ({
54
+ environments: s.m(Sury.array(entrySchema))
55
+ }));
56
+
57
+ function environmentOf(entry) {
58
+ let match = entry.pullRequestsInto;
59
+ let match$1 = entry.branch;
60
+ let match$2 = entry.tags;
61
+ if (match !== undefined) {
62
+ if (match$1 === undefined && match$2 === undefined) {
63
+ if (Stdlib_Option.isSome(entry.stack)) {
64
+ return {
65
+ TAG: "Error",
66
+ _0: entry.name + `: a review environment names its stacks itself; remove stack`
67
+ };
68
+ } else {
69
+ return {
70
+ TAG: "Ok",
71
+ _0: {
72
+ name: entry.name,
73
+ purpose: entry.purpose,
74
+ feed: {
75
+ TAG: "ReviewOf",
76
+ into: match,
77
+ drafts: Stdlib_Option.getOr(entry.drafts, false)
78
+ }
79
+ }
80
+ };
81
+ }
82
+ }
83
+ } else {
84
+ let exit = 0;
85
+ if (match$1 !== undefined) {
86
+ exit = 2;
87
+ } else {
88
+ if (match$2 === undefined) {
89
+ return {
90
+ TAG: "Error",
91
+ _0: entry.name + `: says neither branch, tags nor pullRequestsInto, so nothing feeds it`
92
+ };
93
+ }
94
+ exit = 2;
95
+ }
96
+ if (exit === 2) {
97
+ if (Stdlib_Option.isSome(entry.drafts)) {
98
+ return {
99
+ TAG: "Error",
100
+ _0: entry.name + `: drafts is only meaningful with pullRequestsInto`
101
+ };
102
+ } else {
103
+ return {
104
+ TAG: "Ok",
105
+ _0: {
106
+ name: entry.name,
107
+ purpose: entry.purpose,
108
+ feed: {
109
+ TAG: "Shared",
110
+ branch: match$1,
111
+ tags: match$2,
112
+ stack: Stdlib_Option.getOr(entry.stack, entry.name)
113
+ }
114
+ }
115
+ };
116
+ }
117
+ }
118
+ }
119
+ return {
120
+ TAG: "Error",
121
+ _0: entry.name + `: pullRequestsInto cannot be combined with branch or tags`
122
+ };
123
+ }
124
+
125
+ function parseString(yamlText) {
126
+ let e;
127
+ try {
128
+ e = {
129
+ TAG: "Ok",
130
+ _0: Sury.parseOrThrow(Yaml.parse(yamlText), fileSchema)
131
+ };
132
+ } catch (raw_err) {
133
+ let err = Primitive_exceptions.internalToException(raw_err);
134
+ e = err.RE_EXN_ID === "JsExn" ? ({
135
+ TAG: "Error",
136
+ _0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), "YAML parse error")
137
+ }) : ({
138
+ TAG: "Error",
139
+ _0: "YAML parse error"
140
+ });
141
+ }
142
+ if (e.TAG !== "Ok") {
143
+ return e;
144
+ }
145
+ let environments = e._0.environments;
146
+ let names = environments.map(e => e.name);
147
+ let duplicate = names.find(n => names.filter(m => m === n).length > 1);
148
+ if (duplicate !== undefined) {
149
+ return {
150
+ TAG: "Error",
151
+ _0: duplicate + `: named twice`
152
+ };
153
+ } else {
154
+ return Stdlib_Array.reduce(environments, {
155
+ TAG: "Ok",
156
+ _0: []
157
+ }, (acc, entry) => {
158
+ let match = environmentOf(entry);
159
+ if (acc.TAG === "Ok") {
160
+ if (match.TAG === "Ok") {
161
+ return {
162
+ TAG: "Ok",
163
+ _0: acc._0.concat([match._0])
164
+ };
165
+ } else {
166
+ return {
167
+ TAG: "Error",
168
+ _0: match._0
169
+ };
170
+ }
171
+ } else {
172
+ return acc;
173
+ }
174
+ });
175
+ }
176
+ }
177
+
178
+ function parseFile(path) {
179
+ try {
180
+ return parseString(Nodefs.readFileSync(path, "utf8"));
181
+ } catch (raw_err) {
182
+ let err = Primitive_exceptions.internalToException(raw_err);
183
+ if (err.RE_EXN_ID === "JsExn") {
184
+ return {
185
+ TAG: "Error",
186
+ _0: Stdlib_Option.getOr(Stdlib_JsExn.message(err._1), `Cannot read ` + path)
187
+ };
188
+ } else {
189
+ return {
190
+ TAG: "Error",
191
+ _0: `Cannot read ` + path
192
+ };
193
+ }
194
+ }
195
+ }
196
+
197
+ function entryOf(environment) {
198
+ let match = environment.feed;
199
+ if (match.TAG !== "Shared") {
200
+ return {
201
+ name: environment.name,
202
+ purpose: environment.purpose,
203
+ pullRequestsInto: match.into,
204
+ drafts: match.drafts ? true : undefined
205
+ };
206
+ }
207
+ let stack = match.stack;
208
+ return {
209
+ name: environment.name,
210
+ purpose: environment.purpose,
211
+ branch: match.branch,
212
+ tags: match.tags,
213
+ stack: stack === environment.name ? undefined : stack
214
+ };
215
+ }
216
+
217
+ function print(environments) {
218
+ return Yaml.stringify(Util_Sury$Reventless.toJson({
219
+ environments: environments.map(entryOf)
220
+ }, fileSchema));
221
+ }
222
+
223
+ let fileName = "environments.yaml";
224
+
225
+ function pathBeside(manifest) {
226
+ return Nodepath.join(Nodepath.dirname(manifest), fileName);
227
+ }
228
+
229
+ function matches(pattern, name) {
230
+ let escaped = pattern.split("**").map(part => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*")).join(".*");
231
+ return new RegExp(`^` + escaped + `$`).test(name);
232
+ }
233
+
234
+ function refOf(ref) {
235
+ if (ref.startsWith("refs/heads/")) {
236
+ return {
237
+ TAG: "Branch",
238
+ _0: ref.slice(11)
239
+ };
240
+ } else if (ref.startsWith("refs/tags/")) {
241
+ return {
242
+ TAG: "Tag",
243
+ _0: ref.slice(10)
244
+ };
245
+ } else {
246
+ return;
247
+ }
248
+ }
249
+
250
+ function resolve(environments, ref) {
251
+ return Stdlib_Array.findMap(environments, param => {
252
+ let feed = param.feed;
253
+ let purpose = param.purpose;
254
+ let name = param.name;
255
+ if (feed.TAG !== "Shared") {
256
+ return;
257
+ }
258
+ let pattern = feed.branch;
259
+ if (pattern !== undefined && ref.TAG === "Branch") {
260
+ if (matches(pattern, ref._0)) {
261
+ return {
262
+ name: name,
263
+ stack: feed.stack,
264
+ purpose: purpose
265
+ };
266
+ } else {
267
+ return;
268
+ }
269
+ }
270
+ let pattern$1 = feed.tags;
271
+ if (pattern$1 !== undefined && ref.TAG !== "Branch" && matches(pattern$1, ref._0)) {
272
+ return {
273
+ name: name,
274
+ stack: feed.stack,
275
+ purpose: purpose
276
+ };
277
+ }
278
+ });
279
+ }
280
+
281
+ function resolvePullRequest(environments, base, number, draft) {
282
+ return Stdlib_Array.findMap(environments, param => {
283
+ let feed = param.feed;
284
+ let name = param.name;
285
+ if (feed.TAG === "Shared" || !(matches(feed.into, base) && (feed.drafts || !draft))) {
286
+ return;
287
+ } else {
288
+ return {
289
+ name: name,
290
+ stack: name + `-pr-` + number.toString(),
291
+ purpose: param.purpose
292
+ };
293
+ }
294
+ });
295
+ }
296
+
297
+ export {
298
+ purposeSchema,
299
+ purposeName,
300
+ entrySchema,
301
+ fileSchema,
302
+ environmentOf,
303
+ parseString,
304
+ parseFile,
305
+ entryOf,
306
+ print,
307
+ fileName,
308
+ pathBeside,
309
+ matches,
310
+ refOf,
311
+ resolve,
312
+ resolvePullRequest,
313
+ }
314
+ /* purposeSchema Not a pure module */
@@ -2,6 +2,23 @@
2
2
  @schema
3
3
  type service = string
4
4
 
5
+ /**
6
+ The issuing caller, classified for `@owner` enforcement (`OwnerScope.toClaim`).
7
+
8
+ Written only by the command generator, from the authenticated identity, so a
9
+ handler can tell an owner from an operator without seeing the caller's groups.
10
+ Absent on every command that did not come through the generator — an
11
+ automation's follow-up, an internal dispatch — which a handler reads as the
12
+ platform acting for itself.
13
+ */
14
+ module CallerClaim = {
15
+ @schema
16
+ type t =
17
+ | Owned({userId: string})
18
+ | Exempt
19
+ | Unidentified
20
+ }
21
+
5
22
  /**
6
23
  Envelope metadata attached to every event and command.
7
24
 
@@ -50,6 +67,10 @@ type meta = {
50
67
  /** Extensible header bag for cross-cutting context (tenantId, feature flags, etc.).
51
68
  Absent when empty — consumers normalise with `->Option.getOr(Dict.make())`. */
52
69
  headers?: dict<string>,
70
+ /** Commands only: the caller as the generator classified it. Deliberately not
71
+ inherited by `deriveMeta`, so an event or a follow-up command never carries
72
+ the claim of the command that caused it. */
73
+ callerClaim?: CallerClaim.t,
53
74
  }
54
75
 
55
76
  /**
@@ -6,6 +6,19 @@ import * as Util_Sury$Reventless from "../util/Util_Sury.res.mjs";
6
6
 
7
7
  let serviceSchema = Sury.string;
8
8
 
9
+ let schema = Sury.union([
10
+ Sury.$schema(s => ({
11
+ TAG: "Owned",
12
+ userId: s.m(Sury.string)
13
+ })),
14
+ Sury.literal("Exempt"),
15
+ Sury.literal("Unidentified")
16
+ ]);
17
+
18
+ let CallerClaim = {
19
+ schema: schema
20
+ };
21
+
9
22
  let metaSchema = Sury.$schema(s => ({
10
23
  service: s.m(serviceSchema),
11
24
  time: s.m(Sury.string),
@@ -16,7 +29,8 @@ let metaSchema = Sury.$schema(s => ({
16
29
  causationId: s.m(Sury.$option(Sury.string)),
17
30
  traceparent: s.m(Sury.$option(Sury.string)),
18
31
  schemaVersion: s.m(Sury.$option(Sury.string)),
19
- headers: s.m(Sury.$option(Sury.record(Sury.string)))
32
+ headers: s.m(Sury.$option(Sury.record(Sury.string))),
33
+ callerClaim: s.m(Sury.$option(schema))
20
34
  }));
21
35
 
22
36
  let contextSchema = Sury.$schema(s => ({
@@ -189,6 +203,7 @@ function composeEventJson$p(id, meta, eventJson) {
189
203
 
190
204
  export {
191
205
  serviceSchema,
206
+ CallerClaim,
192
207
  metaSchema,
193
208
  contextSchema,
194
209
  statusChangeSchema,
@@ -207,6 +207,78 @@ let isExempt = (scope: t): bool =>
207
207
  | Owned(_) | Unidentified(_) => false
208
208
  }
209
209
 
210
+ /**
211
+ The classification as it travels on a command's envelope.
212
+
213
+ `System` and `Elevated` collapse into `Exempt` here, unlike in `t`: the handler
214
+ that reads the claim only asks whether the caller may act for anyone, and the
215
+ audit distinction is already on the envelope as `meta.user`.
216
+ */
217
+ let toClaim = (scope: t): Message.CallerClaim.t =>
218
+ switch scope {
219
+ | Owned({userId}) => Owned({userId: userId})
220
+ | System | Elevated(_) => Exempt
221
+ | Unidentified(_) => Unidentified
222
+ }
223
+
224
+ /**
225
+ Whether a command may act on a partition whose history records an owner.
226
+
227
+ `ConflictingOwners` comes first and holds for every caller: two owners in one
228
+ partition is a data defect, and picking either would be guessing whose thing it
229
+ is. `CallerUnidentified` holds even on an empty history — a creating command
230
+ from nobody has no owner to stamp.
231
+ */
232
+ type acting =
233
+ | MayAct
234
+ | NotTheOwner({owner: string})
235
+ | CallerUnidentified
236
+ | ConflictingOwners(array<string>)
237
+ | OwnerUnreadable
238
+
239
+ /**
240
+ Apply the rule to a command's claim and the distinct owners its partition records.
241
+
242
+ `recordedOwners: None` says the handler could not name the partition the command
243
+ acts on, so it could not read who owns it. That refuses an owned caller rather
244
+ than reading as "no owner yet", which would let anyone act.
245
+
246
+ A missing claim is the platform acting for itself: the generator writes one on
247
+ every command it issues, so a command without one reached the handler by an
248
+ internal route — the same routes `@authorize` already trusts.
249
+ */
250
+ let decideActing = (
251
+ ~claim: option<Message.CallerClaim.t>,
252
+ ~recordedOwners: option<array<string>>,
253
+ ): acting =>
254
+ switch (recordedOwners, claim) {
255
+ | (Some(owners), _) if owners->Array.length > 1 => ConflictingOwners(owners)
256
+ | (_, None | Some(Exempt)) => MayAct
257
+ | (_, Some(Unidentified)) => CallerUnidentified
258
+ | (None, Some(Owned(_))) => OwnerUnreadable
259
+ | (Some(owners), Some(Owned({userId}))) =>
260
+ switch owners[0] {
261
+ | Some(owner) if owner != userId => NotTheOwner({owner: owner})
262
+ | Some(_) | None => MayAct
263
+ }
264
+ }
265
+
266
+ /**
267
+ Add the owners one recorded event names to the ones already folded.
268
+
269
+ `fields` are that event type's `@owner` fields (`Owner.fieldNamesByVariant`).
270
+ Distinct, so a partition whose every event repeats the same owner folds to one.
271
+ */
272
+ let recordOwners = (owners: array<string>, ~fields: array<string>, data: dict<JSON.t>): array<
273
+ string,
274
+ > =>
275
+ fields->Array.reduce(owners, (acc, field) =>
276
+ switch data->Dict.get(field)->Option.flatMap(JSON.Decode.string) {
277
+ | Some(owner) if !(acc->Array.includes(owner)) => acc->Array.concat([owner])
278
+ | _ => acc
279
+ }
280
+ )
281
+
210
282
  /**
211
283
  What owner scoping does to one read of one view.
212
284
 
@@ -1,6 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
6
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
6
7
  import * as Identity$Reventless from "./Identity.res.mjs";
@@ -133,6 +134,75 @@ function isExempt(scope) {
133
134
  }
134
135
  }
135
136
 
137
+ function toClaim(scope) {
138
+ if (typeof scope !== "object") {
139
+ return "Exempt";
140
+ }
141
+ switch (scope.TAG) {
142
+ case "Elevated" :
143
+ return "Exempt";
144
+ case "Owned" :
145
+ return {
146
+ TAG: "Owned",
147
+ userId: scope.userId
148
+ };
149
+ case "Unidentified" :
150
+ return "Unidentified";
151
+ }
152
+ }
153
+
154
+ function decideActing(claim, recordedOwners) {
155
+ if (recordedOwners !== undefined) {
156
+ if (recordedOwners.length > 1) {
157
+ return {
158
+ TAG: "ConflictingOwners",
159
+ _0: recordedOwners
160
+ };
161
+ }
162
+ if (claim !== undefined) {
163
+ if (typeof claim !== "object") {
164
+ claim === "Exempt";
165
+ } else {
166
+ let owner = recordedOwners[0];
167
+ if (owner !== undefined && owner !== claim.userId) {
168
+ return {
169
+ TAG: "NotTheOwner",
170
+ owner: owner
171
+ };
172
+ } else {
173
+ return "MayAct";
174
+ }
175
+ }
176
+ }
177
+ } else if (claim !== undefined) {
178
+ if (typeof claim === "object") {
179
+ return "OwnerUnreadable";
180
+ }
181
+ claim === "Exempt";
182
+ }
183
+ if (claim === undefined) {
184
+ return "MayAct";
185
+ }
186
+ if (typeof claim !== "object") {
187
+ if (claim === "Exempt") {
188
+ return "MayAct";
189
+ } else {
190
+ return "CallerUnidentified";
191
+ }
192
+ }
193
+ }
194
+
195
+ function recordOwners(owners, fields, data) {
196
+ return Stdlib_Array.reduce(fields, owners, (acc, field) => {
197
+ let owner = Stdlib_Option.flatMap(data[field], Stdlib_JSON.Decode.string);
198
+ if (owner !== undefined && !acc.includes(owner)) {
199
+ return acc.concat([owner]);
200
+ } else {
201
+ return acc;
202
+ }
203
+ });
204
+ }
205
+
136
206
  function decide(identity, ownerField, elevatedOpt) {
137
207
  let elevated = elevatedOpt !== undefined ? elevatedOpt : elevatedGroups();
138
208
  if (ownerField === undefined) {
@@ -244,6 +314,9 @@ export {
244
314
  resolve,
245
315
  ownerId,
246
316
  isExempt,
317
+ toClaim,
318
+ decideActing,
319
+ recordOwners,
247
320
  decide,
248
321
  scopeOf,
249
322
  decideRetired,