diff --git a/bin/fm-crosscheck-azure.py b/bin/fm-crosscheck-azure.py index c3ea311f106..94d6a5728a2 100755 --- a/bin/fm-crosscheck-azure.py +++ b/bin/fm-crosscheck-azure.py @@ -417,6 +417,160 @@ def verify_scope_and_foundation(config: dict[str, Any]) -> Any: return runner +# The image build declaration (docs/azure-crosscheck/model-image.json) writes +# one attestation tag per reviewer harness onto the managed image it +# distributes, taking every digest from the pinned closure +# (docs/azure-crosscheck/model-image-closure.json). Until this guard landed +# nothing read those tags. PR #246 exists because of what that costs: every Pi +# reviewer that reached a live model VM died on `pi: command not found`, one +# paid VM per attempt, because admission never compared the harness it was +# about to dispatch against what the configured image actually carries. +# +# `pi` binds two tags. Pi ships a `#!/usr/bin/env node` entrypoint and declares +# `engines.node >= 22.19.0`, so an image carrying `pi` without the pinned Node +# runtime fails the reviewer at launch for the same reason and at the same +# cost as an image carrying no `pi` at all. +MODEL_IMAGE_CLOSURE = ROOT / "docs" / "azure-crosscheck" / "model-image-closure.json" +GALLERY_IMAGE_VERSION_API_VERSION = "2023-07-03" +MANAGED_IMAGE_API_VERSION = "2024-03-01" +HARNESS_IMAGE_ATTESTATION: dict[str, tuple[tuple[str, str], ...]] = { + "pi": ( + ("pi-tarball-sha256", "piTarballSha256"), + ("node-tarball-sha256", "nodeTarballSha256"), + ), + "codex": (("codex-cli-sha256", "codexCliSha256"),), +} + + +def image_api_version(resource_id: str) -> str: + lowered = resource_id.lower() + if "/galleries/" in lowered and "/versions/" in lowered: + return GALLERY_IMAGE_VERSION_API_VERSION + return MANAGED_IMAGE_API_VERSION + + +def read_image_tags( + config: dict[str, Any], resource_id: str, label: str +) -> tuple[dict[str, str], str | None]: + """Read one image resource's tags and its source image, failing closed. + + An unreadable resource and an unreadable tag object are both refusals: + this guard exists to stand between a wrong image and a paid VM, so it may + never admit on ambiguity. An ARM resource with no `tags` at all is not + ambiguous - it is an image that attests nothing - so that reads as an + empty tag set and the caller refuses it as absence. + """ + + url = ( + "https://management.azure.com" + + resource_id + + "?api-version=" + + image_api_version(resource_id) + ) + resource, rc, detail = az( + config, ["rest", "--method", "get", "--url", url], check=False + ) + if rc != 0 or not isinstance(resource, dict): + diagnostic = detail.strip()[-400:] if isinstance(detail, str) else "" + raise AzureCrosscheckError( + "Azure Crosscheck model image is unreadable, so its harness " + f"attestation is unproven: {label} {resource_id}: " + + (diagnostic or "no diagnostic") + ) + tags = resource.get("tags") + if tags is None: + tags = {} + if not isinstance(tags, dict) or not all( + isinstance(key, str) and isinstance(value, str) for key, value in tags.items() + ): + raise AzureCrosscheckError( + "Azure Crosscheck model image exposes no readable tags, so its " + f"harness attestation is unproven: {label} {resource_id}" + ) + properties = resource.get("properties") + storage = properties.get("storageProfile") if isinstance(properties, dict) else None + source = storage.get("source") if isinstance(storage, dict) else None + source_id = source.get("id") if isinstance(source, dict) else None + if not isinstance(source_id, str) or not source_id.startswith("/subscriptions/"): + source_id = None + return tags, source_id + + +def pinned_image_closure() -> dict[str, Any]: + try: + value = json.loads(MODEL_IMAGE_CLOSURE.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise AzureCrosscheckError( + "Azure Crosscheck pinned image closure is unreadable, so the model " + f"image attestation cannot be compared: {exc}" + ) from exc + if not isinstance(value, dict): + raise AzureCrosscheckError( + "Azure Crosscheck pinned image closure is unreadable, so the model " + "image attestation cannot be compared: it is not an object" + ) + return value + + +def require_model_image_attests_harness( + config: dict[str, Any], harness: str +) -> dict[str, str]: + """Refuse a model image that does not attest the harness about to run. + + This is a preflight refusal and nothing more: when it passes, the lane + does exactly what it did before. It proves the configured image was built + from a declaration carrying that harness's pinned artifact, which is not + the same claim as the harness executing successfully inside the guest. + """ + + expected_tags = HARNESS_IMAGE_ATTESTATION.get(harness) + if not expected_tags: + raise AzureCrosscheckError( + "Azure Crosscheck has no image attestation for reviewer harness " + f"{harness!r}" + ) + closure = pinned_image_closure() + image_id = config["model_image_id"] + tags, source_id = read_image_tags(config, image_id, "configured image") + source_tags: dict[str, str] | None = None + attested: dict[str, str] = {} + for tag, closure_key in expected_tags: + value = tags.get(tag) + if value is None and source_id is not None: + # The build writes its artifactTags onto the managed image it + # distributes; promoting that image into a gallery image version + # is a separate operator step that need not carry them, so the + # source is followed exactly once before absence is declared. + if source_tags is None: + source_tags, _ = read_image_tags( + config, source_id, "source managed image" + ) + value = source_tags.get(tag) + if value is None: + raise AzureCrosscheckError( + "Azure Crosscheck model image does not attest reviewer harness " + f"{harness!r}: attestation tag {tag!r} is absent from {image_id}" + + (f" and from its source {source_id}" if source_id else "") + + "; refusing before any model VM" + ) + entry = closure.get(closure_key) + pinned = entry.get("value") if isinstance(entry, dict) else None + if not isinstance(pinned, str) or not pinned: + raise AzureCrosscheckError( + "Azure Crosscheck pinned image closure is unreadable, so the " + "model image attestation cannot be compared: " + f"{closure_key!r} is missing" + ) + if value != pinned: + raise AzureCrosscheckError( + "Azure Crosscheck model image attestation " + f"{tag!r} disagrees with pinned closure {closure_key!r}: image " + f"{value} is not closure {pinned}; refusing before any model VM" + ) + attested[tag] = value + return attested + + # R6 (docs/azure-requirements.md): these pins must equal the constants in # bin/fm-crosscheck.py; tests/fm-crosscheck-azure.test.sh enforces the # equality. The GLM lane binds exactly one Foundry resource + deployment and @@ -1685,6 +1839,16 @@ def _run_azure_review_in_lane( # The lane locks are the queue authority; this live-VM read is only a # safety cap against leaked or foreign reviewer compute. raise core.CrosscheckToolError("Azure review admission reached its local model concurrency safety cap") + # Nothing billable exists yet: no capacity reservation, no staged object, + # no model VM. This is the last point at which a model image that does not + # attest the harness this review dispatches can be refused for free, so + # the attestation tags the build writes are read here. The refusal is a + # tool failure rather than a hard error because the same image can attest + # a different harness, which is exactly what reviewer rotation is for. + try: + require_model_image_attests_harness(azure, config["harness"]) + except AzureCrosscheckError as exc: + raise core.CrosscheckToolError(str(exc)) from exc config["account_selector"] = { "codex": "CODEX_HOME", "pi": "PI_CODING_AGENT_DIR", diff --git a/docs/azure-crosscheck.md b/docs/azure-crosscheck.md index 08a0d4d4f84..049d09aef73 100644 --- a/docs/azure-crosscheck.md +++ b/docs/azure-crosscheck.md @@ -88,6 +88,7 @@ The interim claude reviewer lane is retired end to end: no `api.anthropic.com` h Honest limit, corrected 2026-08-20: this Azure-compartment GLM lane does not run today because it is switched off, not because the image lacks `pi`. `$FM_HOME/config/crosscheck-azure.json` exists and carries `"enabled": false`, set by an operator on 2026-08-20; that flag, not an image rebake, is what stands between this lane and a run. The executable GLM lane today is the local Pi reviewer. The earlier reading of this limit said the built image carries no `pi` binary and needed a rebake. That was measured on 2026-08-16 against gallery version `1.0.1786915905`, whose source managed image `img-fm7c799d-ccm-1.0.0` was built on 2026-08-13 from the pre-Pi declaration and carries no `pi-tarball-sha256` tag (M29 in the owner's mutation ledger, `firstmate-azure-full-completion-mutation-ledger.md`, which lives outside this repository rather than in it). It was already stale when it was written here: `model_image_id` has named `1.0.1787092687` since 2026-08-18T22:45Z. That current version was published 2026-08-18T22:38:08Z from managed image `img-fm7c799d-ccm-1.0.1787091895`, which carries `pi-tarball-sha256` `a69a1859...` and `node-tarball-sha256` `d60acfe0...`, matching `docs/azure-crosscheck/model-image-closure.json` for `pi-coding-agent` 0.84.1 and Node v22.23.2. Only a build from the Pi-carrying declaration writes those tags, its Image Builder run succeeded, and that declaration asserts `/usr/local/bin/pi --version` against the tracked version twice under `set -eu`, before and after the credential purge, so a build that reached distribution cannot have omitted `pi`. What remains unproven is a Pi review actually completing on this image, which is a separate claim from the binary being present. +Both readings were guesses about an image that admission never inspected. It does now: the harness attestation guard described under Operator setup reads `pi-tarball-sha256` and `node-tarball-sha256` off the configured image before any model VM exists, so the next time this question is asked the lane answers it from the image rather than from a document, and a wrong `model_image_id` is refused for free instead of discovered on a paid VM. The 25K TPM quota cap (DataZoneStandard capacity 25) bounds review throughput until quota is raised. The model process has no Azure CLI credential, managed identity, SSH agent, Docker socket, repository checkout, control-home mount, MCP configuration, or shell/read tool. @@ -193,7 +194,14 @@ Plan legs are read-only; `image-build` and `policy-apply` are billable/security- Record the exact built image resource ID before any live review. `image-build` distributes a managed image; the reviewer SKUs in [`azure-crosscheck/compartment.json`](azure-crosscheck/compartment.json) need the `DiskControllerTypes` feature a managed image cannot carry, so an operator promotes that managed image into a Compute Gallery image version and it is the gallery version's resource ID that `model_image_id` names. That promotion is the one step of this contract the bounded command does not own, so a rebuilt image reaches reviews only after it is promoted and `model_image_id` is repointed. -Admission does not check that the configured image actually carries the harness it admits: the build tags the image with its `pi-tarball-sha256`, `codex-cli-sha256`, and `claude-cli-sha256`, and nothing reads those tags. Until it does, pointing `model_image_id` at an image built before a harness was added admits that harness and fails it inside a paid VM. +Admission refuses a model image that does not attest the reviewer harness it is about to dispatch. +The build writes `pi-tarball-sha256`, `node-tarball-sha256`, `codex-cli-sha256`, and `claude-cli-sha256` onto the managed image it distributes, from the pinned closure; `require_model_image_attests_harness` in [`bin/fm-crosscheck-azure.py`](../bin/fm-crosscheck-azure.py) now reads them, so those previously unread tags are load-bearing. +Closing that read closes the gap PR #246 recorded: pointing `model_image_id` at an image built before a harness was added used to admit that harness and fail it inside a paid VM, one VM per attempt, on `pi: command not found`. +The check runs after the lane is held and after the foundation preflight, but before the capacity reservation, before any staged object, and before the model VM, so a refusal costs nothing. +It reads the configured image's own tags with one read-only ARM GET, and follows the version's source managed image exactly once when a required tag is absent there, because gallery promotion is a separate operator step that need not carry `artifactTags`. +A tag that is absent refuses, a tag that disagrees with the tracked closure digest refuses and names which digest disagreed, and an unreadable image, unreadable source, or unreadable tag object refuses rather than admitting: the guard never admits on ambiguity. +`pi` binds two tags, its tarball and its Node runtime, because Pi ships a `#!/usr/bin/env node` entrypoint and an image carrying `pi` without the pinned Node fails the reviewer at launch for the same reason and at the same cost. +Honest limit: this proves the configured image attests a harness, not that the harness runs. It reads what the build recorded about the image; a review completing on that image remains a separate claim, and a harness not in the attestation table (the retired `claude` lane) is refused rather than admitted. One more Pi-lane cost is open: the model guest launches `pi` without `--offline`, and the compartment's egress allowlist is Azure DNS plus one provider endpoint, so Pi's startup update and telemetry calls are dropped rather than refused and each waits out its own timeout on a paid VM before the review begins. The pinned closure is tracked at [`azure-crosscheck/model-image-closure.json`](azure-crosscheck/model-image-closure.json). diff --git a/tests/fm-crosscheck-azure.test.sh b/tests/fm-crosscheck-azure.test.sh index dc077cfb0d1..c78874162ee 100755 --- a/tests/fm-crosscheck-azure.test.sh +++ b/tests/fm-crosscheck-azure.test.sh @@ -849,6 +849,288 @@ documented_acceptance_contract() { pass "operator documentation enumerates malicious, concurrency, fault, force-push, and cloud-default acceptance" } +image_attestation_guard_unit() { + python3 - "$ADAPTER" "$ROOT/docs/azure-crosscheck/model-image-closure.json" "$DOC" <<'PYIMG' || fail "model image harness attestation guard failed" +import ast +import importlib.util +import json +from pathlib import Path +import sys + +adapter_path, closure_path, doc_path = map(Path, sys.argv[1:]) +spec = importlib.util.spec_from_file_location("azure_image_attestation", adapter_path) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + +closure = json.loads(closure_path.read_text(encoding="utf-8")) +PI = closure["piTarballSha256"]["value"] +NODE = closure["nodeTarballSha256"]["value"] +CODEX = closure["codexCliSha256"]["value"] +SUBSCRIPTION = "5f0f9efb-723c-4bd8-a2e2-ba13625ea014" +GALLERY = ( + "/subscriptions/%s/resourceGroups/rg-firstmate-pilot-eastus-001/providers" + "/Microsoft.Compute/galleries/sig_fm7c799d/images/crosscheck-model" + "/versions/1.0.1787092687" % SUBSCRIPTION +) +MANAGED = ( + "/subscriptions/%s/resourceGroups/rg-firstmate-pilot-eastus-001/providers" + "/Microsoft.Compute/images/img-fm7c799d-ccm-1.0.1787091895" % SUBSCRIPTION +) +CONFIG = {"subscription": SUBSCRIPTION, "model_image_id": GALLERY} + +requested = [] + + +def install_az(replies): + """Serve exact ARM GETs by resource id and record every URL requested.""" + + def call(config, args, *, check=True): + assert config is CONFIG, "the guard read a foreign config" + assert args[:3] == ["rest", "--method", "get"], args + assert check is False, "an image read must not raise past the guard" + url = args[args.index("--url") + 1] + requested.append(url) + for resource_id, reply in replies.items(): + if url.startswith("https://management.azure.com" + resource_id + "?"): + return reply + raise AssertionError("guard requested an unexpected resource: " + url) + + del requested[:] + m.az = call + + +def gallery(tags, source=MANAGED): + body = {"id": GALLERY, "tags": tags} + if source is not None: + body["properties"] = {"storageProfile": {"source": {"id": source}}} + return (body, 0, "") + + +def managed(tags): + return ({"id": MANAGED, "tags": tags}, 0, "") + + +def refusal(harness, config=None): + try: + m.require_model_image_attests_harness(config or CONFIG, harness) + except m.AzureCrosscheckError as exc: + return str(exc) + raise AssertionError("the guard admitted harness %r" % harness) + + +# (a) An image whose source managed image carries the pinned harness digests +# admits, and the guard returns exactly what it proved. The digests are read +# from the tracked closure, so a closure change cannot leave this green by +# comparing a constant to itself. +install_az({ + GALLERY: gallery({"firstmate-role": "crosscheck-model-image"}), + MANAGED: managed({"pi-tarball-sha256": PI, "node-tarball-sha256": NODE}), +}) +assert m.require_model_image_attests_harness(CONFIG, "pi") == { + "pi-tarball-sha256": PI, + "node-tarball-sha256": NODE, +} +# The source is followed exactly once even though two tags were resolved from +# it, and each resource is read at its own api-version: a gallery image +# version and a managed image are different resource types. +assert len(requested) == 2, requested +assert requested[0].endswith("?api-version=" + m.GALLERY_IMAGE_VERSION_API_VERSION) +assert requested[1].endswith("?api-version=" + m.MANAGED_IMAGE_API_VERSION) + +# The same image admits from its own tags with no source read at all, which is +# the shape a gallery promotion that copies artifactTags produces. +install_az({GALLERY: gallery({"pi-tarball-sha256": PI, "node-tarball-sha256": NODE})}) +assert m.require_model_image_attests_harness(CONFIG, "pi") +assert len(requested) == 1, requested + +# (b) ABSENCE refuses. This is the failure that burned VMs: an image built +# before a harness was added carries no tag for it at all. +install_az({ + GALLERY: gallery({}, source=None), + MANAGED: managed({"pi-tarball-sha256": PI, "node-tarball-sha256": NODE}), +}) +message = refusal("pi") +assert "does not attest reviewer harness 'pi'" in message, message +assert "attestation tag 'pi-tarball-sha256' is absent" in message, message +assert "refusing before any model VM" in message, message +assert GALLERY in message, message + +# Absence of the second bound tag refuses on its own: Pi runs under a +# `#!/usr/bin/env node` entrypoint, so an image with pi and no pinned Node +# dies at reviewer launch in the same paid VM. +install_az({ + GALLERY: gallery({}), + MANAGED: managed({"pi-tarball-sha256": PI}), +}) +message = refusal("pi") +assert "attestation tag 'node-tarball-sha256' is absent" in message, message +assert MANAGED in message, message + +# (c) A MISMATCHED digest refuses with its own string naming which digest +# disagreed, and carries both sides so an operator can tell which image booted. +install_az({ + GALLERY: gallery({}), + MANAGED: managed({"pi-tarball-sha256": "0" * 64, "node-tarball-sha256": NODE}), +}) +message = refusal("pi") +assert "disagrees with pinned closure 'piTarballSha256'" in message, message +assert "'pi-tarball-sha256'" in message, message +assert "0" * 64 in message and PI in message, message +assert "absent" not in message, message +# The node digest is the one that disagrees when it is the one substituted. +install_az({ + GALLERY: gallery({}), + MANAGED: managed({"pi-tarball-sha256": PI, "node-tarball-sha256": "1" * 64}), +}) +message = refusal("pi") +assert "disagrees with pinned closure 'nodeTarballSha256'" in message, message + +# (d) Fail closed. An unreadable image, an unreadable source, and an +# unreadable tag object are all refusals, never admissions. +install_az({GALLERY: (None, 3, "ERROR: (AuthorizationFailed) no read on the gallery")}) +message = refusal("pi") +assert "model image is unreadable" in message, message +assert "AuthorizationFailed" in message, message + +install_az({ + GALLERY: gallery({}), + MANAGED: (None, 3, "ERROR: (ResourceNotFound) the managed image is gone"), +}) +message = refusal("pi") +assert "model image is unreadable" in message, message +assert "source managed image" in message, message + +install_az({GALLERY: ("not-a-resource", 0, "")}) +assert "model image is unreadable" in refusal("pi") + +for hostile in ("pi-tarball-sha256", ["pi-tarball-sha256"], {"pi-tarball-sha256": 7}): + install_az({GALLERY: gallery(hostile, source=None)}) + message = refusal("pi") + assert "exposes no readable tags" in message, (hostile, message) + +# A resource that reports no tags at all is not ambiguous - it attests +# nothing - so it refuses as absence rather than as unreadability. +install_az({GALLERY: ({"id": GALLERY}, 0, "")}) +assert "is absent" in refusal("pi") + +# (e) A harness other than the one attested refuses. The image below carries a +# complete Pi closure and no codex digest at all. +install_az({ + GALLERY: gallery({}), + MANAGED: managed({"pi-tarball-sha256": PI, "node-tarball-sha256": NODE}), +}) +message = refusal("codex") +assert "does not attest reviewer harness 'codex'" in message, message +assert "attestation tag 'codex-cli-sha256' is absent" in message, message +# And the codex lane admits on an image that does attest it. +install_az({ + GALLERY: gallery({}), + MANAGED: managed({"codex-cli-sha256": CODEX}), +}) +assert m.require_model_image_attests_harness(CONFIG, "codex") == { + "codex-cli-sha256": CODEX +} + +# A harness with no attestation mapping refuses rather than defaulting to +# admitted; the retired claude lane is exactly such a harness. +for unknown in ("claude", "", "pi "): + install_az({}) + message = refusal(unknown) + assert "no image attestation for reviewer harness" in message, message + +# An unreadable pinned closure refuses too: presence alone is not the check +# this guard promises. +install_az({ + GALLERY: gallery({}), + MANAGED: managed({"pi-tarball-sha256": PI, "node-tarball-sha256": NODE}), +}) +real_closure = m.MODEL_IMAGE_CLOSURE +try: + m.MODEL_IMAGE_CLOSURE = closure_path.with_name("model-image-closure.absent.json") + assert "pinned image closure is unreadable" in refusal("pi") +finally: + m.MODEL_IMAGE_CLOSURE = real_closure + +# CALL SITE. Everything above proves the function; this proves the lane calls +# it, with the harness it is actually about to dispatch, before anything +# billable exists. Read from CODE, so a comment describing the guard cannot +# keep this green after the call is deleted. +source = adapter_path.read_text(encoding="utf-8") +lane = next( + node for node in ast.walk(ast.parse(source)) + if isinstance(node, ast.FunctionDef) and node.name == "_run_azure_review_in_lane" +) + + +def call_lines(name): + return sorted( + node.lineno for node in ast.walk(lane) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == name + ) + + +guard = call_lines("require_model_image_attests_harness") +assert len(guard) == 1, ("the lane does not call the image attestation guard", guard) +for billable in ( + "reserve_model_capacity", + "upload_blob", + "provision_model_vm", + "submit_model_run", +): + spends = call_lines(billable) + assert spends, "the lane no longer calls " + billable + assert guard[0] < spends[0], ( + "the image attestation guard runs after billable work: " + billable + ) +guard_call = next( + node for node in ast.walk(lane) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "require_model_image_attests_harness" +) +# The dispatched harness, not a literal: an image checked against a hardcoded +# harness would admit every other one. +harness_argument = guard_call.args[1] +assert isinstance(harness_argument, ast.Subscript), ast.dump(harness_argument) +assert isinstance(harness_argument.value, ast.Name) +assert harness_argument.value.id == "config" +assert harness_argument.slice.value == "harness", ast.dump(harness_argument) + +# Every harness the adapter can dispatch must have an attestation, and the +# table may not accumulate entries for harnesses no lane dispatches. A new +# lane added without a tag is refused rather than admitted, so this is a +# drift check and not the safety boundary. +assert set(m.HARNESS_IMAGE_ATTESTATION) == set(m.HARNESS_PROVIDER_HOSTS), ( + sorted(set(m.HARNESS_IMAGE_ATTESTATION) ^ set(m.HARNESS_PROVIDER_HOSTS)) +) + +# The declaration must keep writing the tags this guard now reads. +declaration = json.loads( + (closure_path.parent / "model-image.json").read_text(encoding="utf-8") +) +artifact_tags = declaration["resources"][0]["properties"]["distribute"][0]["artifactTags"] +for harness, bindings in m.HARNESS_IMAGE_ATTESTATION.items(): + for tag, closure_key in bindings: + assert "'%s'" % tag in artifact_tags, (harness, tag) + assert "parameters('%s')" % closure_key in artifact_tags, (harness, closure_key) + assert closure_key in closure, closure_key + +# The owning doc must record that these tags are load-bearing and must not +# still describe the read as missing. +text = doc_path.read_text(encoding="utf-8") +for phrase in ( + "Admission refuses a model image that does not attest the reviewer harness", + "load-bearing", + "attests a harness, not that the harness runs", +): + assert phrase in text, phrase +assert "nothing reads those tags" not in text +PYIMG + pass "admission refuses a model image that does not attest the dispatched reviewer harness" +} + shared_capacity_unit() { python3 - "$ROOT/bin/fm-crosscheck-azure.py" <<'PY' || fail "shared model capacity binding failed" import importlib.util,json,os,sys,tempfile,types @@ -1267,5 +1549,6 @@ replay_positive_and_failure_unit shared_capacity_unit lane_queue_unit image_and_policy_contract +image_attestation_guard_unit documented_acceptance_contract printf 'Azure Crosscheck tests passed.\n'