Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4585,7 +4585,11 @@ the repository's own policy:
```toml
# policy/principles.toml
[decide]
adopt = ["unix-composability", "defense-in-depth", "economy-of-mechanism"]
adopt = [
{ id = "unix-composability", why = "every gate here is a command a script can run" },
"defense-in-depth",
"economy-of-mechanism",
]

[[decide.order]]
prefer = "defense-in-depth"
Expand All @@ -4598,7 +4602,13 @@ over = "defense-in-depth"
why = "Each layer is a separate command that can be run and tested alone."
```

`adopt` is catalog ids. Every pair of adopted records that names the other in
`adopt` is catalog ids. An entry is the id alone, or a table `{ id, why }`
whose `why` is this repository's reason for adopting the record; both forms
mix in one list. The record says what it holds and is the same text for every
consumer, so the reason belongs to the consumer. `why` is optional, where an
order's is required, because an adoption is reviewable from the record alone.
It is printed and never parsed: a ruling cites the records and the orders, and
reads an adoption's `why` as context. Every pair of adopted records that names the other in
`conflicts_with` takes exactly one `[[decide.order]]`: `prefer` is the record
whose answer wins where the two disagree, `over` the one it wins over, and `why`
the reason, for whoever reviews the order. `conflicts_with` is symmetric, so
Expand All @@ -4610,19 +4620,22 @@ what a repository rules by is one command:

```text
adopted 3 record(s) for design rulings ([decide]):
unix-composability
unix-composability: every gate here is a command a script can run
defense-in-depth
economy-of-mechanism
order defense-in-depth over economy-of-mechanism: A second control is ...
order unix-composability over defense-in-depth: Each layer is a separate ...
```

Exit `1`, under `design rulings refused ([decide]):`, for an adopted id the
catalog does not define or keeps only as deprecated, an adopted conflict with no
catalog does not define or keeps only as deprecated, an adoption `why` that is
the record's `summary` or `claim` copied, an adopted conflict with no
order, an order naming a record the repository does not adopt, an order over two
records that do not conflict, and a second order over a pair already ordered.
Exit `2`, at load and so from every command that reads the policy, for a table
whose shape is wrong: an unknown key, an `order` missing `prefer`, `over` or
`why` or holding a blank one, an id adopted twice, and `[decide]` in a bundled
`why` or holding a blank one, an id adopted twice, an adopt entry that is
neither an id nor a table, an adopt table with a key other than `id` and
`why` or a blank `why`, and `[decide]` in a bundled
set or an `inherit.paths` file, since which records a repository rules by is
that repository's own declaration. `uphold schema` describes the table.
34 changes: 32 additions & 2 deletions docs/schema/principles.schema.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
{
"$defs": {
"Adopt": {
"anyOf": [
{
"description": "A record id.",
"type": "string"
},
{
"$ref": "#/$defs/Reasoned",
"description": "A record id with this repository's reason for adopting it."
}
],
"description": "One `[decide] adopt` entry: a record id, written alone or as `{ id, why }`.\n\nThe `why` is the consumer's fact, not the catalog's: the record says what it\nholds, and this says why this repository rules by it. It is printed by\n`uphold check` and never parsed, so it is context for a ruling and never a\ncitation in one. It is optional, where an order's is required: an adoption\nis reviewable from the record alone, an order is not.\n\nSerialized in the form it was written, so a string entry stays a string in\n`uphold check --json`."
},
"Collect": {
"description": "How this command's subjects are found.",
"oneOf": [
Expand Down Expand Up @@ -45,9 +58,9 @@
"properties": {
"adopt": {
"default": [],
"description": "Catalog record ids, each at most once.",
"description": "Catalog record ids, each at most once. An entry is an id, or a table\n`{ id, why }` whose `why` is this repository's reason for adopting it.",
"items": {
"type": "string"
"$ref": "#/$defs/Adopt"
},
"type": "array"
},
Expand Down Expand Up @@ -379,6 +392,23 @@
}
]
},
"Reasoned": {
"additionalProperties": false,
"description": "The table form of an adopt entry.",
"properties": {
"id": {
"type": "string"
},
"why": {
"description": "Why this repository adopts the record, in its own words. Refused at\nload when blank; `uphold check` refuses one that copies the record's\n`summary` or `claim`.",
"type": "string"
}
},
"required": [
"id"
],
"type": "object"
},
"Remote": {
"additionalProperties": false,
"description": "A consumer-selected policy blob, identified by GitHub repository, immutable\ncommit and path. The loader also uses this exact tuple as rule provenance.",
Expand Down
3 changes: 3 additions & 0 deletions skills/design-ruling/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ must be traceable to them.
Run `uphold check --json` from the repository root and read `decide`:

- `adopt` is the record ids the repository answers design questions from.
An entry may be a table `{id, why}` instead of an id. Its `why` is the
repository's reason for adopting the record: context for reading the
record's scope, never a citation, and never a ground for an answer.
- `order` is one `{prefer, over, why}` per adopted pair that conflicts.

If `uphold check` exits 1 with `design rulings refused ([decide])`, escalate
Expand Down
6 changes: 6 additions & 0 deletions src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ pub(crate) struct Record {
pub id: String,
#[serde(default)]
pub status: String,
/// Read so `[decide]` can refuse an adoption `why` that copies it.
#[serde(default)]
pub summary: String,
/// As `summary`.
#[serde(default)]
pub claim: String,
#[serde(default)]
pub enforcement: Enforcement,
/// Symmetric, as `scripts/validate.py` holds it: an adopted pair in it is
Expand Down
35 changes: 29 additions & 6 deletions src/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ use std::path::Path;
use serde::{Deserialize, Serialize};

use crate::catalog;
use crate::config::{Decide, Policy};
use crate::config::{Adopt, Decide, Policy};
use crate::error::{Exit, Fatal, Result, verdict};

const DECLARATION: &str = "policy/upheld.toml";
Expand Down Expand Up @@ -1090,8 +1090,11 @@ fn report_human(report: &Report) -> Result<Exit> {
"adopted {} record(s) for design rulings ([decide]):",
report.decide.adopt.len()
);
for id in &report.decide.adopt {
println!(" {id}");
for entry in &report.decide.adopt {
match entry.why() {
Some(why) => println!(" {}: {why}", entry.id()),
None => println!(" {}", entry.id()),
}
}
for order in &report.decide.order {
println!(
Expand All @@ -1103,9 +1106,21 @@ fn report_human(report: &Report) -> Result<Exit> {
Ok(report.verdict.exit())
}

/// The record field an adoption's `why` repeats verbatim, if any: the
/// catalog's text in a second place, which drifts from it and says nothing
/// about why this repository adopted the record.
fn copied_field(entry: &Adopt, record: &catalog::Record) -> Option<&'static str> {
let why = entry.why()?.trim();
[("summary", &record.summary), ("claim", &record.claim)]
.into_iter()
.find(|(_, text)| text.trim() == why)
.map(|(field, _)| field)
}

/// Hold `[decide]` to the catalog this binary carries, into `report`.
///
/// Every id must be a live record, every adopted pair that one names in
/// Every id must be a live record, no adoption's `why` may copy the record's
/// text, every adopted pair that one names in
/// `conflicts_with` must carry exactly one order, and every order must be over
/// such a pair. An adopted conflict with no order is two answers to one
/// question, which is the thing an order exists to remove; an order over
Expand All @@ -1115,14 +1130,22 @@ fn decided(report: &mut Report, decide: &Decide) -> Result<()> {
let mut failures = Vec::new();
let known = catalog::ids()?.len();
let mut adopted: BTreeMap<&str, &catalog::Record> = BTreeMap::new();
for id in &decide.adopt {
for entry in &decide.adopt {
let id = entry.id();
match catalog::get(id)? {
None => failures.push(format!("adopt: {}", catalog::unknown(id, known))),
Some(record) if record.deprecated() => failures.push(format!(
"adopt: {id:?} is deprecated; the catalog keeps it for redirects only"
)),
Some(record) => {
adopted.insert(id.as_str(), record);
if let Some(copied) = copied_field(entry, record) {
failures.push(format!(
"adopt: {id:?} has a `why` that is the record's `{copied}`. The \
catalog already says what the record holds; `why` says why this \
repository adopts it"
));
}
adopted.insert(id, record);
}
}
}
Expand Down
91 changes: 87 additions & 4 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -731,15 +731,90 @@ impl Stop {
#[derive(Debug, Clone, Default, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct Decide {
/// Catalog record ids, each at most once.
/// Catalog record ids, each at most once. An entry is an id, or a table
/// `{ id, why }` whose `why` is this repository's reason for adopting it.
#[serde(default)]
pub adopt: Vec<String>,
pub adopt: Vec<Adopt>,
/// `[[decide.order]]`: one per pair of adopted records that names the
/// other in `conflicts_with`.
#[serde(default)]
pub order: Vec<Order>,
}

/// One `[decide] adopt` entry: a record id, written alone or as `{ id, why }`.
///
/// The `why` is the consumer's fact, not the catalog's: the record says what it
/// holds, and this says why this repository rules by it. It is printed by
/// `uphold check` and never parsed, so it is context for a ruling and never a
/// citation in one. It is optional, where an order's is required: an adoption
/// is reviewable from the record alone, an order is not.
///
/// Serialized in the form it was written, so a string entry stays a string in
/// `uphold check --json`.
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
#[serde(untagged)]
pub(crate) enum Adopt {
/// A record id.
Id(String),
/// A record id with this repository's reason for adopting it.
Reasoned(Reasoned),
}

/// The table form of an adopt entry.
#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct Reasoned {
pub id: String,
/// Why this repository adopts the record, in its own words. Refused at
/// load when blank; `uphold check` refuses one that copies the record's
/// `summary` or `claim`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub why: Option<String>,
}

impl Adopt {
pub(crate) fn id(&self) -> &str {
match self {
Self::Id(id) | Self::Reasoned(Reasoned { id, .. }) => id,
}
}

pub(crate) fn why(&self) -> Option<&str> {
match self {
Self::Id(_) => None,
Self::Reasoned(reasoned) => reasoned.why.as_deref(),
}
}
}

/// Written by hand rather than `#[serde(untagged)]`, whose only error is "did
/// not match any variant": a table with a misspelt key must be told which key,
/// as every other table in the policy is.
impl<'de> Deserialize<'de> for Adopt {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = Adopt;
fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("a record id, or a table `{ id, why }`")
}
fn visit_str<E: serde::de::Error>(self, id: &str) -> std::result::Result<Adopt, E> {
Ok(Adopt::Id(id.to_owned()))
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
map: A,
) -> std::result::Result<Adopt, A::Error> {
Reasoned::deserialize(serde::de::value::MapAccessDeserializer::new(map))
.map(Adopt::Reasoned)
}
}
deserializer.deserialize_any(Visitor)
}
}

/// One `[[decide.order]]`: where `prefer` and `over` disagree, the answer
/// is `prefer`'s, for the reason `why` gives.
#[derive(Debug, Clone, Deserialize, Serialize, schemars::JsonSchema)]
Expand All @@ -754,13 +829,21 @@ pub(crate) struct Order {
impl Decide {
fn validate(&self) -> std::result::Result<(), String> {
let mut seen = BTreeSet::new();
for id in &self.adopt {
for entry in &self.adopt {
let id = entry.id();
if id.trim().is_empty() {
return Err("`[decide] adopt` holds a blank id".to_owned());
}
if !seen.insert(id.as_str()) {
if !seen.insert(id) {
return Err(format!("`[decide] adopt` names {id:?} twice"));
}
let Some(why) = entry.why() else { continue };
if why.trim().is_empty() {
return Err(format!(
"`[decide] adopt` entry {id:?} has a blank `why`. Give this repository's \
reason for adopting it, or write the id alone"
));
}
}
for order in &self.order {
for (field, value) in [
Expand Down
Loading
Loading