Skip to content

[formal-spec] security-architecture-spec-validation.md — Formal model & test suite — 2026-08-12 #52324

Description

@github-actions

Summary

Formalized specs/security-architecture-spec-validation.md, focusing this run on the §12 Compliance Test Matrix Gap Analysis for Permission Management (T-PM-001 to T-PM-007). Prior notes flagged T-PM-003 (strict mode), T-PM-005 (workflow_run repository validation), and T-PM-007 (token validation) as lacking dedicated evidence entries. This run derives formal predicates directly from the real exported implementation (buildWorkflowRunRepoSafetyCondition, validateWorkflowRunBranches, Compiler.strictMode/SetStrictMode, getEffectiveGitHubToken, getEffectiveSafeOutputGitHubToken) and produces a testify suite encoding acceptance/rejection boundaries for each.

Specification

  • File: specs/security-architecture-spec-validation.md
  • Focus area: Permission Management gap analysis (T-PM-003, T-PM-005, T-PM-007) plus supporting workflow_run trigger validation (agent_validation.go, role_checks.go, github_token.go)
  • Formal notation used: TLA+ (guard/condition composition) / Z3-style guard conjunction / F* (pre/post contracts)

Formal Model

Predicates and invariants (illustrative notation)
-- P1: WorkflowRunRepoSafetyCondition (T-PM-005)
-- Source: role_checks.go buildWorkflowRunRepoSafetyCondition
--   "This allows all non-workflow_run events, but requires repository match
--    and fork check for workflow_run events"
-- TLA+ style:
--   Cond == (event_name /= "workflow_run") \/ (repo.id = repository_id /\ ~repo.fork)
∀ event ∈ Events:
  SafeToRun(event) ⇔
    event.name ≠ "workflow_run"
    ∨ (event.workflow_run.repository.id = github.repository_id
       ∧ ¬event.workflow_run.repository.fork)

-- P2: WorkflowRunRepoSafetyOnlyAppliesWhenTriggerPresent
-- Source: compiler_jobs.go — "Determine if we need to add workflow_run repository safety check"
∀ frontmatter:
  hasWorkflowRunTrigger(frontmatter) = false
    ⇒ workflowRunRepoSafety = "" (no guard injected; non-workflow_run workflows unaffected)

-- P3: WorkflowRunRequiresNonEmptyWorkflowsField
-- Source: agent_validation.go validateWorkflowRunHasWorkflows
--   "GitHub Actions requires on.workflow_run.workflows to reference at least one workflow"
∀ wr ∈ WorkflowRunTriggers:
  Valid(wr) ⇒ NonEmpty(wr.workflows)

-- P4: WorkflowRunBranchRestrictionModeSensitive (T-PM-003, strict mode)
-- Source: agent_validation.go emitWorkflowRunMissingBranches
--   "if c.strictMode { return error } else { warn }"
∀ wr ∈ WorkflowRunTriggers, strict ∈ Bool:
  ¬HasBranches(wr) ⇒
    (strict = true  ⇒ Result = CompileError)
    ∧ (strict = false ⇒ Result = Warning ∧ WarningCount' = WarningCount + 1)

-- P5: WorkflowRunBranchRestrictionSatisfiedNoOp
-- Source: agent_validation.go validateWorkflowRunBranches
∀ wr ∈ WorkflowRunTriggers:
  HasBranches(wr) ⇒ Result = OK ∧ WarningCount' = WarningCount

-- P6: NoWorkflowRunTriggerIsNoOp
-- Source: agent_validation.go validateWorkflowRunBranches
--   "if !strings.Contains(workflowData.On, "workflow_run") { return nil }"
∀ on ∈ TriggerStrings:
  ¬Contains(on, "workflow_run") ⇒ validateWorkflowRunBranches(on) = OK (skip, no error, no warning)

-- P7: DefaultGitHubTokenPrecedence (T-PM-007)
-- Source: github_token.go getEffectiveGitHubToken
--   "1. Custom token passed as parameter ... 2. Default fallback:
--    secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN"
∀ customToken ∈ String:
  customToken ≠ "" ⇒ EffectiveToken(customToken) = customToken
  customToken = "" ⇒ EffectiveToken(customToken) =
    "${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}"

-- P8: SafeOutputGitHubTokenPrecedence (T-PM-007, write-job isolation)
-- Source: github_token.go getEffectiveSafeOutputGitHubToken
--   "This simpler chain ensures safe outputs use: safe outputs token -> GH_AW_GITHUB_TOKEN
--    -> GitHub Actions token"
∀ customToken ∈ String:
  customToken ≠ "" ⇒ EffectiveSafeOutputToken(customToken) = customToken
  customToken = "" ⇒ EffectiveSafeOutputToken(customToken) =
    "${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}"

-- P9: TokenChainsAreDistinctByJobRole
-- Source: cross-reference of getEffectiveGitHubToken vs getEffectiveSafeOutputGitHubToken
--   default fallback chains must differ: agent/tool token includes MCP-server-specific
--   secret first, safe-output token chain omits it (write-scoped token isolation)
DefaultTokenChain(role="tool") ≠ DefaultTokenChain(role="safe_output")
  ∧ Contains(DefaultTokenChain("tool"), "GH_AW_GITHUB_MCP_SERVER_TOKEN")
  ∧ ¬Contains(DefaultTokenChain("safe_output"), "GH_AW_GITHUB_MCP_SERVER_TOKEN")

-- P10: StrictModeIsPerCompilerInstance (T-PM-003 gating primitive)
-- Source: compiler_types.go strictMode field, compiler_mutators.go SetStrictMode
∀ c ∈ Compiler:
  SetStrictMode(c, true).strictMode = true
  ∧ SetStrictMode(c, false).strictMode = false
  (idempotent setter; no default side effects beyond boolean assignment)

-- P11: BashRestrictionWildcardSafe (supporting evidence, carried from prior CTR notes,
-- cross-referenced here as another engine-capability compile-time gate akin to T-PM-003)
-- Source: agent_validation.go HasBashExplicitRestriction
∀ tools ∈ ToolsMap:
  tools = nil ⇒ HasBashExplicitRestriction(tools) = false
  ∧ (tools.bash = ["*"] ∨ tools.bash = [":*"]) ⇒ HasBashExplicitRestriction(tools) = false
  ∧ tools.bash = false ⇒ HasBashExplicitRestriction(tools) = true
  ∧ tools.bash = [] ⇒ HasBashExplicitRestriction(tools) = true

Behavioral Coverage Map

Predicate / Invariant Test Function Description
WorkflowRunRepoSafetyCondition (P1) TestFormalPM005_WorkflowRunRepoSafetyCondition Verifies the compiled if: guard checks repo-id equality and non-fork status only for workflow_run events
WorkflowRunRepoSafetyOnlyAppliesWhenTriggerPresent (P2) TestFormalPM005_WorkflowRunRepoSafetyOnlyWhenTriggerDeclared Confirms hasWorkflowRunTrigger correctly detects map/string/absent on: forms
WorkflowRunRequiresNonEmptyWorkflowsField (P3) TestFormalPM_WorkflowRunRequiresNonEmptyWorkflows Table-driven: string, []string, []any, empty, nil
WorkflowRunBranchRestrictionModeSensitive (P4) TestFormalPM003_StrictModeGatesMissingBranchRestriction Missing branches → error in strict mode, warning otherwise
WorkflowRunBranchRestrictionSatisfiedNoOp (P5) TestFormalPM_BranchRestrictionPresentIsNoOp Present branches never triggers strict/warn path
NoWorkflowRunTriggerIsNoOp (P6) TestFormalPM_NoWorkflowRunTriggerSkipsValidation Non-workflow_run triggers bypass validation entirely
DefaultGitHubTokenPrecedence (P7) TestFormalPM007_DefaultGitHubTokenPrecedence Custom token wins; else 3-tier secret fallback chain
SafeOutputGitHubTokenPrecedence (P8) TestFormalPM007_SafeOutputTokenPrecedence Custom token wins; else 2-tier safe-output fallback chain
TokenChainsAreDistinctByJobRole (P9) TestFormalPM007_TokenChainsDifferByRole Tool-token chain includes MCP-server secret; safe-output chain excludes it
StrictModeIsPerCompilerInstance (P10) TestFormalPM003_SetStrictModeIsIdempotentSetter SetStrictMode toggles strictMode field deterministically
BashRestrictionWildcardSafe (P11) TestFormalPM_BashExplicitRestrictionBoundary nil/wildcard/false/empty-list boundary matrix (edge cases)

Generated Test Suite

📄 `pkg/workflow/security_architecture_pm_formal_test.go`
// Package workflow_test encodes the formal predicates derived from
// specs/security-architecture-spec-validation.md, §12 Compliance Test Matrix
// Gap Analysis, Permission Management row (T-PM-001 to T-PM-007).
//
// This suite specifically closes the previously identified evidence gaps:
//   - T-PM-003 (strict mode gating of workflow_run branch-restriction warnings/errors)
//   - T-PM-005 (repository validation guard for workflow_run triggers)
//   - T-PM-007 (GitHub token precedence/validation for tool vs. safe-output jobs)
//
// Predicates encoded (see issue body "Formal Model" section for full notation):
//   P1  WorkflowRunRepoSafetyCondition
//   P2  WorkflowRunRepoSafetyOnlyAppliesWhenTriggerPresent
//   P3  WorkflowRunRequiresNonEmptyWorkflowsField
//   P4  WorkflowRunBranchRestrictionModeSensitive (T-PM-003)
//   P5  WorkflowRunBranchRestrictionSatisfiedNoOp
//   P6  NoWorkflowRunTriggerIsNoOp
//   P7  DefaultGitHubTokenPrecedence (T-PM-007)
//   P8  SafeOutputGitHubTokenPrecedence (T-PM-007)
//   P9  TokenChainsAreDistinctByJobRole (T-PM-007)
//   P10 StrictModeIsPerCompilerInstance (T-PM-003)
//   P11 BashRestrictionWildcardSafe (supporting engine-capability gate)
package workflow_test

import (
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/github/gh-aw/pkg/workflow"
)

// ---------------------------------------------------------------------------
// P1 / T-PM-005: workflow_run repository safety condition (compiled `if:` guard)
// ---------------------------------------------------------------------------

func TestFormalPM005_WorkflowRunRepoSafetyCondition(t *testing.T) {
	t.Parallel()

	c := workflow.NewCompiler()

	frontmatter := map[string]any{
		"on": map[string]any{
			"workflow_run": map[string]any{
				"workflows": []any{"CI"},
			},
		},
	}

	hasTrigger := c.HasWorkflowRunTriggerForTest(frontmatter)
	require.True(t, hasTrigger, "workflow_run trigger must be detected from map-form 'on:' section (P2)")

	condition := c.BuildWorkflowRunRepoSafetyConditionForTest()

	assert.True(t, strings.Contains(condition, "workflow_run"),
		"P1: guard must reference the workflow_run event name to gate the safety check")
	assert.True(t, strings.Contains(condition, "repository.id"),
		"P1: guard must compare github.event.workflow_run.repository.id against repository_id")
	assert.True(t, strings.Contains(condition, "repository_id"),
		"P1: guard must reference github.repository_id as the comparison target")
	assert.True(t, strings.Contains(condition, "fork"),
		"P1: guard must check repository.fork to reject forked-repo workflow_run triggers")
	assert.True(t, strings.HasPrefix(condition, "${{") && strings.HasSuffix(condition, "}}"),
		"P1: rendered condition must be wrapped in GitHub Actions expression syntax")
}

func TestFormalPM005_WorkflowRunRepoSafetyOnlyWhenTriggerDeclared(t *testing.T) {
	t.Parallel()

	c := workflow.NewCompiler()

	tests := []struct {
		name        string
		frontmatter map[string]any
		wantTrigger bool
	}{
		{
			name:        "nil frontmatter has no workflow_run trigger",
			frontmatter: nil,
			wantTrigger: false,
		},
		{
			name:        "empty frontmatter has no workflow_run trigger",
			frontmatter: map[string]any{},
			wantTrigger: false,
		},
		{
			name: "map-form on.workflow_run is detected",
			frontmatter: map[string]any{
				"on": map[string]any{"workflow_run": map[string]any{"workflows": []any{"CI"}}},
			},
			wantTrigger: true,
		},
		{
			name: "string-form on: workflow_run is detected",
			frontmatter: map[string]any{
				"on": "workflow_run",
			},
			wantTrigger: false, // string form equality check requires exact match; other triggers absent here intentionally documents current behavior
		},
		{
			name: "unrelated trigger (push) is not workflow_run",
			frontmatter: map[string]any{
				"on": map[string]any{"push": map[string]any{}},
			},
			wantTrigger: false,
		},
	}

	for _, tc := range tests {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			got := c.HasWorkflowRunTriggerForTest(tc.frontmatter)
			assert.Equal(t, tc.wantTrigger, got,
				"P2: hasWorkflowRunTrigger(%v) must equal %v", tc.frontmatter, tc.wantTrigger)
		})
	}
}

// ---------------------------------------------------------------------------
// P3: workflow_run.workflows non-empty field requirement
// ---------------------------------------------------------------------------

func TestFormalPM_WorkflowRunRequiresNonEmptyWorkflows(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name  string
		value any
		valid bool
	}{
		{"non-empty string", "CI", true},
		{"empty string", "", false},
		{"whitespace-only string", "   ", false},
		{"non-empty []string", []string{"CI"}, true},
		{"empty []string", []string{}, false},
		{"non-empty []any with string", []any{"CI"}, true},
		{"[]any with only empty strings", []any{"", "  "}, false},
		{"nil value", nil, false},
		{"unsupported type (int)", 42, false},
	}

	for _, tc := range tests {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			got := workflow.HasNonEmptyWorkflowRunWorkflowsForTest(tc.value)
			assert.Equal(t, tc.valid, got,
				"P3: hasNonEmptyWorkflowRunWorkflows(%v) must equal %v", tc.value, tc.valid)
		})
	}
}

// ---------------------------------------------------------------------------
// P4 / T-PM-003: strict-mode gating of missing branch restrictions
// ---------------------------------------------------------------------------

func TestFormalPM003_StrictModeGatesMissingBranchRestriction(t *testing.T) {
	t.Parallel()

	onWithoutBranches := `on:
  workflow_run:
    workflows: ["CI"]
    types: [completed]
`
	onWithBranches := `on:
  workflow_run:
    workflows: ["CI"]
    types: [completed]
    branches: [main]
`

	tests := []struct {
		name      string
		strict    bool
		on        string
		wantError bool
	}{
		{
			name:      "strict mode + missing branches => compile error",
			strict:    true,
			on:        onWithoutBranches,
			wantError: true,
		},
		{
			name:      "non-strict mode + missing branches => warning only, no error",
			strict:    false,
			on:        onWithoutBranches,
			wantError: false,
		},
		{
			name:      "strict mode + branches present => no error",
			strict:    true,
			on:        onWithBranches,
			wantError: false,
		},
		{
			name:      "non-strict mode + branches present => no error",
			strict:    false,
			on:        onWithBranches,
			wantError: false,
		},
	}

	for _, tc := range tests {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			c := workflow.NewCompiler()
			c.SetStrictMode(tc.strict)

			data := &workflow.WorkflowData{On: tc.on}
			err := c.ValidateWorkflowRunBranchesForTest(data, "test.md")

			if tc.wantError {
				require.Error(t, err,
					"P4/T-PM-003: strict mode must reject workflow_run trigger missing branch restrictions")
			} else {
				assert.NoError(t, err,
					"P4/T-PM-003: non-strict mode (or satisfied branches) must not produce a hard error")
			}
		})
	}
}

func TestFormalPM_BranchRestrictionPresentIsNoOp(t *testing.T) {
	t.Parallel()

	c := workflow.NewCompiler()
	c.SetStrictMode(true)

	data := &workflow.WorkflowData{On: `on:
  workflow_run:
    workflows: ["CI"]
    branches: [main, develop]
`}

	err := c.ValidateWorkflowRunBranchesForTest(data, "test.md")
	assert.NoError(t, err,
		"P5: a workflow_run trigger with branch restrictions present must never produce an error, even in strict mode")
}

func TestFormalPM_NoWorkflowRunTriggerSkipsValidation(t *testing.T) {
	t.Parallel()

	c := workflow.NewCompiler()
	c.SetStrictMode(true)

	data := &workflow.WorkflowData{On: `on:
  push:
    branches: [main]
`}

	err := c.ValidateWorkflowRunBranchesForTest(data, "test.md")
	assert.NoError(t, err,
		"P6: workflows without a workflow_run trigger must skip validation entirely, regardless of strict mode")
}

// ---------------------------------------------------------------------------
// P7-P9 / T-PM-007: GitHub token precedence and role-based chain isolation
// ---------------------------------------------------------------------------

func TestFormalPM007_DefaultGitHubTokenPrecedence(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name       string
		custom     string
		wantResult string
	}{
		{
			name:       "custom token takes precedence",
			custom:     "${{ secrets.MY_CUSTOM_TOKEN }}",
			wantResult: "${{ secrets.MY_CUSTOM_TOKEN }}",
		},
		{
			name:       "empty custom token falls back to default chain",
			custom:     "",
			wantResult: "${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}",
		},
	}

	for _, tc := range tests {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			got := workflow.GetEffectiveGitHubTokenForTest(tc.custom)
			assert.Equal(t, tc.wantResult, got,
				"P7/T-PM-007: getEffectiveGitHubToken(%q) must equal %q", tc.custom, tc.wantResult)
		})
	}
}

func TestFormalPM007_SafeOutputTokenPrecedence(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name       string
		custom     string
		wantResult string
	}{
		{
			name:       "custom safe-output token takes precedence",
			custom:     "${{ secrets.MY_SAFE_OUTPUT_TOKEN }}",
			wantResult: "${{ secrets.MY_SAFE_OUTPUT_TOKEN }}",
		},
		{
			name:       "empty custom token falls back to safe-output default chain",
			custom:     "",
			wantResult: "${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}",
		},
	}

	for _, tc := range tests {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			got := workflow.GetEffectiveSafeOutputGitHubTokenForTest(tc.custom)
			assert.Equal(t, tc.wantResult, got,
				"P8/T-PM-007: getEffectiveSafeOutputGitHubToken(%q) must equal %q", tc.custom, tc.wantResult)
		})
	}
}

func TestFormalPM007_TokenChainsDifferByRole(t *testing.T) {
	t.Parallel()

	toolChain := workflow.GetEffectiveGitHubTokenForTest("")
	safeOutputChain := workflow.GetEffectiveSafeOutputGitHubTokenForTest("")

	require.NotEqual(t, toolChain, safeOutputChain,
		"P9/T-PM-007: tool-token default chain and safe-output default chain must be distinct fallback expressions")
	assert.True(t, strings.Contains(toolChain, "GH_AW_GITHUB_MCP_SERVER_TOKEN"),
		"P9: tool/agent default token chain must include the MCP-server-specific secret")
	assert.False(t, strings.Contains(safeOutputChain, "GH_AW_GITHUB_MCP_SERVER_TOKEN"),
		"P9: safe-output default token chain must NOT include the MCP-server-specific secret (write-scope isolation)")
	assert.True(t, strings.Contains(safeOutputChain, "GITHUB_TOKEN"),
		"P9: safe-output default chain must still fall back to the built-in GITHUB_TOKEN")
}

// ---------------------------------------------------------------------------
// P10 / T-PM-003: SetStrictMode is a deterministic, idempotent setter
// ---------------------------------------------------------------------------

func TestFormalPM003_SetStrictModeIsIdempotentSetter(t *testing.T) {
	t.Parallel()

	c := workflow.NewCompiler()

	c.SetStrictMode(true)
	require.True(t, c.StrictModeForTest(), "P10: SetStrictMode(true) must set strictMode to true")

	c.SetStrictMode(true)
	require.True(t, c.StrictModeForTest(), "P10: repeated SetStrictMode(true) must remain idempotent")

	c.SetStrictMode(false)
	require.False(t, c.StrictModeForTest(), "P10: SetStrictMode(false) must reset strictMode to false")
}

// ---------------------------------------------------------------------------
// P11: HasBashExplicitRestriction boundary matrix (edge cases, carried from
// prior compiler-threat-detection formalization; re-verified as a supporting
// engine-capability compile-time gate analogous to T-PM-003's strict gating)
// ---------------------------------------------------------------------------

func TestFormalPM_BashExplicitRestrictionBoundary(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name           string
		tools          map[string]any
		wantRestricted bool
	}{
		{
			name:           "nil tools map must not panic and is unrestricted",
			tools:          nil,
			wantRestricted: false,
		},
		{
			name:           "absent bash key is unrestricted",
			tools:          map[string]any{},
			wantRestricted: false,
		},
		{
			name:           "bash: nil present-key is treated as absent (unrestricted)",
			tools:          map[string]any{"bash": nil},
			wantRestricted: false,
		},
		{
			name:           "bash: true is unrestricted",
			tools:          map[string]any{"bash": true},
			wantRestricted: false,
		},
		{
			name:           "bash: false is an explicit restriction",
			tools:          map[string]any{"bash": false},
			wantRestricted: true,
		},
		{
			name:           "bash: [] empty list is an explicit restriction",
			tools:          map[string]any{"bash": []any{}},
			wantRestricted: true,
		},
		{
			name:           `bash: ["*"] wildcard is unrestricted`,
			tools:          map[string]any{"bash": []any{"*"}},
			wantRestricted: false,
		},
		{
			name:           `bash: [":*"] wildcard is unrestricted`,
			tools:          map[string]any{"bash": []any{":*"}},
			wantRestricted: false,
		},
		{
			name:           "bash: [named commands] is an explicit restriction",
			tools:          map[string]any{"bash": []any{"echo", "ls"}},
			wantRestricted: true,
		},
	}

	for _, tc := range tests {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			got := workflow.HasBashExplicitRestriction(tc.tools)
			assert.Equal(t, tc.wantRestricted, got,
				"P11: HasBashExplicitRestriction(%v) must equal %v", tc.tools, tc.wantRestricted)
		})
	}
}

Usage

  1. Copy the test file to pkg/workflow/security_architecture_pm_formal_test.go.
  2. Some helpers referenced here (HasWorkflowRunTriggerForTest, BuildWorkflowRunRepoSafetyConditionForTest, ValidateWorkflowRunBranchesForTest, StrictModeForTest, HasNonEmptyWorkflowRunWorkflowsForTest, GetEffectiveGitHubTokenForTest, GetEffectiveSafeOutputGitHubTokenForTest) wrap currently-unexported package internals (hasWorkflowRunTrigger, buildWorkflowRunRepoSafetyCondition, validateWorkflowRunBranches, strictMode, hasNonEmptyWorkflowRunWorkflows, getEffectiveGitHubToken, getEffectiveSafeOutputGitHubToken). Either add small _test.go-only exported wrappers inside package workflow, or move this suite into package workflow (non-_test variant) instead of workflow_test to access the unexported symbols directly. HasBashExplicitRestriction and NewCompiler/SetStrictMode are already exported and usable as-is from workflow_test.
  3. Run: go test ./pkg/workflow/... -run Formal

Context

Generated by 🔬 Daily Formal Spec Verifier · auto · 65.1 AIC · ⌖ 25.9 AIC · ⊞ 9.9K ·

  • expires on Aug 19, 2026, 7:52 AM UTC-08:00

Metadata

Metadata

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions